├── .nvmrc ├── .prettierrc ├── .versionrc ├── functions ├── .nvmrc ├── .eslintignore ├── .gitignore ├── src │ ├── index.ts │ ├── constants.ts │ ├── football-api.ts │ ├── prediction.ts │ └── form.ts ├── .env.default ├── .eslintrc.json ├── .gcloudignore ├── tsconfig.json └── package.json ├── pages ├── index.tsx ├── [league] │ ├── gd │ │ ├── index.tsx │ │ ├── team-by-half.tsx │ │ ├── team-by-half-conceded.tsx │ │ └── cumulative.tsx │ ├── gf │ │ ├── index.tsx │ │ └── cumulative.tsx │ ├── ga │ │ ├── index.tsx │ │ └── cumulative.tsx │ ├── index.tsx │ ├── results │ │ ├── first-half.tsx │ │ ├── second-half.tsx │ │ ├── halftime-after-drawing.tsx │ │ ├── halftime-after-leading.tsx │ │ └── halftime-after-losing.tsx │ ├── facts │ │ ├── names-order.tsx │ │ ├── names-length.tsx │ │ └── form.tsx │ ├── substitutes │ │ ├── earliest.tsx │ │ ├── earliest-rolling.tsx │ │ ├── most-at-once.tsx │ │ └── earliest-multiple.tsx │ ├── stats │ │ ├── comparison │ │ │ └── [type].tsx │ │ ├── [type].tsx │ │ ├── finishing │ │ │ └── index.tsx │ │ └── rolling │ │ │ ├── [type] │ │ │ └── index.tsx │ │ │ └── finishing.tsx │ ├── chart │ │ └── [period].tsx │ ├── first-goal │ │ ├── [type].tsx │ │ └── rolling │ │ │ └── [type] │ │ │ └── [period].tsx │ ├── gd-chart │ │ └── [period].tsx │ ├── gf-chart │ │ └── [period].tsx │ ├── ga-chart │ │ └── [period].tsx │ ├── player-minutes │ │ └── index.tsx │ ├── points │ │ ├── cumulative.tsx │ │ └── off-top.tsx │ ├── game-days │ │ ├── since.tsx │ │ ├── since-home.tsx │ │ └── since │ │ │ └── [period].tsx │ ├── game-states │ │ ├── lost-leads.tsx │ │ ├── comebacks.tsx │ │ └── index.tsx │ ├── fixtures │ │ ├── index.tsx │ │ └── today.tsx │ ├── ppg │ │ ├── team.tsx │ │ ├── differential.tsx │ │ ├── opponent.tsx │ │ └── outcomes.tsx │ ├── versus │ │ ├── gd.tsx │ │ ├── record.tsx │ │ └── index.tsx │ ├── xg │ │ ├── for.tsx │ │ ├── against.tsx │ │ └── difference.tsx │ ├── since-result │ │ ├── opponent │ │ │ └── index.tsx │ │ ├── [result].tsx │ │ ├── away │ │ │ └── [result].tsx │ │ └── home │ │ │ └── [result].tsx │ ├── plus-minus │ │ └── index.tsx │ └── record │ │ └── since │ │ └── [date].tsx ├── changelog.tsx ├── api │ ├── fixture │ │ └── [fixture].ts │ ├── admin │ │ ├── all-fixtures.ts │ │ └── load-fixtures.ts │ ├── form.ts │ ├── player-stats │ │ └── minutes.ts │ ├── theodds │ │ └── [league].ts │ └── fixtures │ │ └── [fixture].ts └── _document.tsx ├── .env.default ├── public ├── ball.png ├── favicon.ico └── vercel.svg ├── .husky ├── prepare-commit-msg └── pre-commit ├── .stylelintrc ├── .vercelignore ├── utils ├── fetcher.ts ├── sort.ts ├── isLeagueAllowed.ts ├── getGoals.ts ├── redis.ts ├── getMatchResultString.ts ├── getExpires.ts ├── cache │ └── getKeys.ts ├── getMatchPoints.ts ├── data.ts ├── __tests__ │ └── cache-test.ts ├── LeagueCodes.ts ├── getTeamPoints.ts ├── array.ts ├── getConsecutiveGames.tsx ├── transform.ts ├── getFormattedValues.ts ├── getLinks.ts ├── getRecord.tsx ├── getPpg.ts ├── api │ └── getFixtureData.ts ├── results.ts ├── getAllFixtureIds.ts └── referee.ts ├── components ├── Context │ ├── EasterEgg.tsx │ ├── DarkMode.tsx │ ├── Year.tsx │ ├── Drawer.tsx │ └── League.tsx ├── Generic │ └── FormattedDate.tsx ├── MatchDescriptor.tsx ├── Toggle │ ├── OpponentToggle.tsx │ ├── HomeAwayToggle.tsx │ ├── RollingToggle.tsx │ ├── ResultToggle.tsx │ ├── RefereeStats.tsx │ ├── PeriodLength.tsx │ └── Toggle.tsx ├── Table.tsx ├── Fixtures │ └── FixtureListItem.tsx ├── Rolling │ ├── BoxV2.tsx │ ├── AbstractBox.tsx │ └── Base.tsx ├── ColorKey.tsx ├── App │ └── LeagueSelect.tsx ├── KBar │ ├── Input.tsx │ └── Provider.tsx ├── DateFilter.tsx ├── Selector │ └── Stats.tsx ├── BaseASADataPage.tsx ├── EasterEgg.tsx ├── BaseASAGridPage.tsx ├── BaseGridPage.tsx ├── BaseDataPage.tsx ├── Grid │ ├── Base.tsx │ └── Cell.tsx ├── Cell.tsx ├── Results.tsx ├── BasePage.tsx └── BaseASARollingPage.tsx ├── styles ├── globals.css └── Home.module.css ├── types ├── calculate-correlation.d.ts ├── theodds.d.ts ├── player-stats.d.ts ├── render.d.ts └── api.d.ts ├── .github ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── test.yml │ ├── gcp-functions-deploy.yml │ ├── codacy.yml │ └── codeql-analysis.yml └── dependabot.yml ├── next.config.js ├── lint-staged.config.js ├── .eslintrc.json ├── pull_request_template.md ├── next-env.d.ts ├── jest.config.ts ├── CONTRIBUTING.md ├── .gitignore ├── tsconfig.json ├── middleware.ts ├── LICENSE ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /functions/.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /functions/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | ../ -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./prediction"; 2 | import "./form"; 3 | -------------------------------------------------------------------------------- /functions/.env.default: -------------------------------------------------------------------------------- 1 | API_FOOTBALL_KEY= 2 | API_FOOTBALL_BASE= 3 | REDIS_URL= -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Home from "./[league]"; 2 | 3 | export default Home; 4 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | FORM_API= 2 | PREDICTIONS_API= 3 | THEODDS_API_KEY= 4 | 5 | REDIS_URL= 6 | -------------------------------------------------------------------------------- /public/ball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmontgomery/formguide/HEAD/public/ball.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmontgomery/formguide/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx devmoji -e --lint 5 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["stylelint-prettier"], 3 | "extends": ["stylelint-prettier/recommended"] 4 | } 5 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | functions 2 | !functions/src/constants.ts 3 | functions/node_modules 4 | data 5 | .husky/ 6 | .github/ -------------------------------------------------------------------------------- /utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | const fetcher = (url: string) => fetch(url).then((r) => r.json()); 2 | 3 | export default fetcher; 4 | -------------------------------------------------------------------------------- /components/Context/EasterEgg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext(false); 4 | -------------------------------------------------------------------------------- /components/Context/DarkMode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DarkMode = React.createContext(false); 4 | 5 | export default DarkMode; 6 | -------------------------------------------------------------------------------- /components/Context/Year.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DEFAULT_YEAR = 2025; 4 | export default React.createContext(DEFAULT_YEAR); 5 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | a { 8 | color: inherit; 9 | text-decoration: none; 10 | } 11 | -------------------------------------------------------------------------------- /components/Context/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DrawerContext = React.createContext(true); 4 | 5 | export default DrawerContext; 6 | -------------------------------------------------------------------------------- /types/calculate-correlation.d.ts: -------------------------------------------------------------------------------- 1 | declare module "calculate-correlation" { 2 | function calculate(a: number[], b: number[]): number; 3 | export default calculate; 4 | } 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | -------------------------------------------------------------------------------- /components/Context/League.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DEFAULT_LEAGUE = "mls"; 4 | export default React.createContext(DEFAULT_LEAGUE); 5 | -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended"], 3 | "root": true, 4 | "rules": { 5 | "quotes": [1, "double", { "allowTemplateLiterals": true }] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { transpile } = require("typescript"); 2 | 3 | module.exports = { 4 | images: { 5 | domains: ["media.api-sports.io"], 6 | }, 7 | transpilePackages: ["@mui/x-data-grid"], 8 | }; 9 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "**/*.{js,jsx,ts,tsx}": [ 3 | () => "npm run lint:next", 4 | () => "npm run lint:build", 5 | ], 6 | "**/*.{scss,css}": [() => "npm run lint:style"], 7 | }; 8 | -------------------------------------------------------------------------------- /utils/sort.ts: -------------------------------------------------------------------------------- 1 | export function sortByDate(a: T, b: T) { 2 | const aDate = new Date(a.rawDate); 3 | const bDate = new Date(b.rawDate); 4 | return aDate > bDate ? 1 : aDate < bDate ? -1 : 0; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "ignorePatterns": ["./*.js", "local/*"] 6 | } 7 | -------------------------------------------------------------------------------- /utils/isLeagueAllowed.ts: -------------------------------------------------------------------------------- 1 | import { LeagueOptions } from "./Leagues"; 2 | 3 | export default function isLeagueAllowed(league: string): boolean { 4 | if (league in LeagueOptions) { 5 | return true; 6 | } 7 | return false; 8 | } 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | 6 | FUNCTION_FILES=$(git diff --cached --name-only functions) 7 | 8 | if test -n "$FUNCTION_FILES"; then 9 | npm test --prefix functions 10 | fi -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | # PR Title 2 | 3 | ## Tasks 4 | 5 | - [ ] Linting passes (`npm run lint`) 6 | - [ ] Application builds (`npm run build`) 7 | - [ ] README.md updated, as applicable 8 | 9 | ## Next steps 10 | 11 | - @mattmontgomery review 12 | -------------------------------------------------------------------------------- /components/Generic/FormattedDate.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | export type FormattedDateProps = { 4 | date: Date; 5 | }; 6 | export default function FormattedDate(props: FormattedDateProps) { 7 | return <>{format(props.date, "MMM d")}; 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /pages/[league]/gd/index.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function GoalDifference(): React.ReactElement { 4 | return ( 5 | match.gd ?? "-"} 8 | /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/[league]/gf/index.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function GoalsFor(): React.ReactElement { 4 | return ( 5 | match.goalsScored ?? "-"} 8 | /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/changelog.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/BasePage"; 2 | import Changelog from "@/components/Changelog"; 3 | 4 | export default function ChangelogPage(): React.ReactElement { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/[league]/ga/index.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function GoalsAgainst(): React.ReactElement { 4 | return ( 5 | match.goalsConceded ?? "-"} 8 | /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/[league]/index.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function Home(): React.ReactElement { 4 | return ( 5 | <> 6 | match.result ?? "-"} 9 | /> 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /pages/[league]/results/first-half.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function FirstHalfResults(): React.ReactElement { 4 | return ( 5 | match.firstHalf?.result} 8 | /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /utils/getGoals.ts: -------------------------------------------------------------------------------- 1 | export function getFirstGoalScored(match: Results.MatchWithGoalData) { 2 | return match.goalsData?.goals.find((g) => g.team.name === match.team); 3 | } 4 | export function getFirstGoalConceded(match: Results.MatchWithGoalData) { 5 | return match.goalsData?.goals.find((g) => g.team.name !== match.team); 6 | } 7 | -------------------------------------------------------------------------------- /pages/[league]/results/second-half.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function DrawingHalftimeResultsPage(): React.ReactElement { 4 | return ( 5 | match.secondHalf?.result ?? "-"} 8 | /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /components/MatchDescriptor.tsx: -------------------------------------------------------------------------------- 1 | export default function MatchDescriptor({ 2 | match, 3 | }: { 4 | match: Results.Match; 5 | }): React.ReactElement { 6 | return ( 7 | <> 8 | {match.home ? match.team : match.opponent} {match.score.fulltime.home}- 9 | {match.score.fulltime.away} {match.home ? match.opponent : match.team} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /utils/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | 3 | export function getClient() { 4 | const self = globalThis as unknown as { __redisClient: Redis }; 5 | if (!self.__redisClient) { 6 | self.__redisClient = new Redis( 7 | process.env.REDIS_URL || "redis://localhost", 8 | ); 9 | } 10 | return self.__redisClient; 11 | } 12 | 13 | export default getClient; 14 | -------------------------------------------------------------------------------- /utils/getMatchResultString.ts: -------------------------------------------------------------------------------- 1 | type PastTenseResult = "Won" | "Lost" | "Drew"; 2 | 3 | const PastTenseFormatMap: Record = { 4 | D: "Drew", 5 | L: "Lost", 6 | W: "Won", 7 | }; 8 | 9 | export function getPastTense(match: Results.Match): PastTenseResult | null { 10 | return match.result ? PastTenseFormatMap[match.result] : null; 11 | } 12 | -------------------------------------------------------------------------------- /utils/getExpires.ts: -------------------------------------------------------------------------------- 1 | const thisYear = new Date().getFullYear(); 2 | 3 | export default function getExpires(year: number): number { 4 | return year === thisYear ? 60 * 15 : 60 * 60 * 24 * 7 * 4; 5 | } 6 | 7 | export function getExpiresWeek(year: number): number { 8 | return year === thisYear ? 60 * 60 * 24 * 7 : 60 * 60 * 24 * 365.25; // one year if not the current year, one week otherwise 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from "ts-jest"; 2 | import { compilerOptions } from "./tsconfig.json"; 3 | import type { JestConfigWithTsJest } from "ts-jest"; 4 | 5 | const jestConfig: JestConfigWithTsJest = { 6 | preset: "ts-jest", 7 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 8 | prefix: "/", 9 | }), 10 | }; 11 | export default jestConfig; 12 | -------------------------------------------------------------------------------- /components/Toggle/OpponentToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./Toggle"; 2 | 3 | export type OpponentToggleOptions = "team" | "opponent"; 4 | 5 | export function useOpponentToggle(show: OpponentToggleOptions = "team") { 6 | return useToggle( 7 | [ 8 | { value: "team", label: "Team" }, 9 | { value: "opponent", label: "Opponent" }, 10 | ], 11 | show, 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/Toggle/HomeAwayToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./Toggle"; 2 | 3 | export type Options = "all" | "home" | "away"; 4 | 5 | export function useHomeAway(defaultValue: Options = "all") { 6 | return useToggle( 7 | [ 8 | { value: "all", label: "All" }, 9 | { value: "home", label: "Home" }, 10 | { value: "away", label: "Away" }, 11 | ], 12 | defaultValue, 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /pages/[league]/results/halftime-after-drawing.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function DrawingHalftimeResultsPage(): React.ReactElement { 4 | return ( 5 | 8 | match.firstHalf?.result === "D" && match.result ? match.result : "-" 9 | } 10 | /> 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /pages/[league]/results/halftime-after-leading.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function DrawingHalftimeResultsPage(): React.ReactElement { 4 | return ( 5 | 8 | match.firstHalf?.result === "W" && match.result ? match.result : "-" 9 | } 10 | /> 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /pages/[league]/results/halftime-after-losing.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function DrawingHalftimeResultsPage(): React.ReactElement { 4 | return ( 5 | 8 | match.firstHalf?.result === "L" && match.result ? match.result : "-" 9 | } 10 | /> 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Form Guide 2 | 3 | Want to contribute? 4 | 5 | - Check out the existing issues. Is there a bug that you want to solve? A new feature you want to build? 6 | - If so, reply to the issue and tell everyone you're going to take a stab at it. 7 | - If not, that's fine. Go for it! Feel free to log a feature request in the issues. 8 | - Fork the project and submit a PR. After feedback and review, we'll review next steps. 9 | -------------------------------------------------------------------------------- /components/Toggle/RollingToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./Toggle"; 2 | 3 | export type Options = 3 | 5 | 8 | 11 | number; 4 | 5 | export function useRolling(defaultValue: Options = 5) { 6 | return useToggle( 7 | [ 8 | { value: 3, label: "3-game" }, 9 | { value: 5, label: "5-game" }, 10 | { value: 8, label: "8-game" }, 11 | { value: 11, label: "11-game" }, 12 | ], 13 | defaultValue, 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /pages/[league]/facts/names-order.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import getConsecutiveGames from "@/utils/getConsecutiveGames"; 3 | 4 | export default function MatchFactsNames(): React.ReactElement { 5 | return ( 6 | getConsecutiveGames(data, Object.keys(data).sort())} 9 | /> 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /utils/cache/getKeys.ts: -------------------------------------------------------------------------------- 1 | import redisClient from "@/utils/redis"; 2 | 3 | export default async function getKeys(pattern: string): Promise { 4 | const keys: string[] = []; 5 | return new Promise((resolve) => { 6 | const stream = redisClient().scanStream({ 7 | match: `${pattern}*`, 8 | count: 1000, 9 | }); 10 | stream.on("data", function (resultKeys) { 11 | keys.push(...resultKeys); 12 | }); 13 | stream.on("end", function () { 14 | resolve(keys); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /components/Toggle/ResultToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./Toggle"; 2 | 3 | export type Options = Results.ResultTypes; 4 | export type OptionsAll = Results.ResultTypesAll; 5 | 6 | export function useResultToggle() { 7 | return useToggle( 8 | [{ value: "W" }, { value: "D" }, { value: "L" }], 9 | "W", 10 | ); 11 | } 12 | export function useResultToggleAll() { 13 | return useToggle( 14 | [{ value: "W" }, { value: "D" }, { value: "L" }, { value: "all" }], 15 | "all", 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /types/theodds.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace TheOdds { 2 | type Entry = { 3 | id: string; 4 | sport_key: string; 5 | sport_title: string; 6 | commence_time: string; 7 | home_team: string; 8 | away_team: string; 9 | bookmakers: Bookmaker[]; 10 | }; 11 | type Bookmaker = { 12 | key: string; 13 | title: string; 14 | last_update: string; 15 | markets: Market[]; 16 | }; 17 | type Market = { 18 | key: "h2h" | "totals" | "spreads"; 19 | outcomes: { 20 | name: string | "Draw"; 21 | price: number; 22 | point?: number; 23 | }[]; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /functions/.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | node_modules 17 | #!include:.gitignore 18 | -------------------------------------------------------------------------------- /components/Toggle/RefereeStats.tsx: -------------------------------------------------------------------------------- 1 | import { useToggle } from "./Toggle"; 2 | 3 | export type RefereeStatOptions = "Yellow Cards" | "Red Cards" | "Fouls"; 4 | 5 | export function useRefereeStatsToggle( 6 | show: RefereeStatOptions = "Yellow Cards", 7 | ) { 8 | return useToggle( 9 | [ 10 | { 11 | value: "Yellow Cards", 12 | label: "Yellow Cards", 13 | }, 14 | { 15 | value: "Red Cards", 16 | label: "Red Cards", 17 | }, 18 | { 19 | value: "Fouls", 20 | label: "Fouls", 21 | }, 22 | ], 23 | show, 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["esnext", "dom"], 4 | "outDir": "build", 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "declaration": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "module": "commonjs", 10 | "noEmitOnError": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "pretty": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es2018", 17 | "rootDir": "." 18 | }, 19 | "exclude": ["node_modules"], 20 | "include": ["src/**/*.ts", "test/**/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /pages/[league]/facts/names-length.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import getConsecutiveGames from "@/utils/getConsecutiveGames"; 3 | 4 | export default function MatchFactsNames(): React.ReactElement { 5 | return ( 6 | 9 | getConsecutiveGames( 10 | data, 11 | Object.keys(data).sort((a, b) => { 12 | return a.length > b.length ? 1 : b.length > a.length ? -1 : 0; 13 | }), 14 | ) 15 | } 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /utils/getMatchPoints.ts: -------------------------------------------------------------------------------- 1 | import { LeagueCodesInverse } from "./LeagueCodes"; 2 | 3 | export default function getMatchPoints(match: Results.Match): number { 4 | if ( 5 | match.league && 6 | LeagueCodesInverse[match.league?.id] === "mlsnp" && 7 | match.status.short === "PEN" 8 | ) { 9 | return match.score.penalty[match.home ? "home" : "away"] > 10 | match.score.penalty[match.home ? "away" : "home"] 11 | ? 2 12 | : 1; 13 | } 14 | 15 | return getPointsForResult(match.result); 16 | } 17 | 18 | export function getPointsForResult(result: Results.ResultType) { 19 | return result === "W" ? 3 : result === "D" ? 1 : 0; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | .env 36 | 37 | .tsbuildinfo 38 | 39 | local/ 40 | 41 | #Ignore vscode AI rules 42 | .github/instructions/codacy.instructions.md 43 | -------------------------------------------------------------------------------- /utils/data.ts: -------------------------------------------------------------------------------- 1 | import { parseISO } from "date-fns"; 2 | import { sortByDate } from "./sort"; 3 | 4 | export function getEarliestMatch(data: Results.ParsedData) { 5 | return [ 6 | ...Object.entries(data.teams).map(([, matches]) => { 7 | return matches[0]; 8 | }), 9 | ].sort(sortByDate)?.[0]; 10 | } 11 | export function getLatestMatch(data: Results.ParsedData) { 12 | return [ 13 | ...Object.entries(data.teams).map(([, matches]) => { 14 | return [...matches].reverse()[0]; 15 | }), 16 | ] 17 | .sort(sortByDate) 18 | .reverse()[0]; 19 | } 20 | 21 | export function getMatchDate(match: T) { 22 | return parseISO(match.rawDate); 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /pages/[league]/substitutes/earliest.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | 6 | export default function EarliestSubstitute(): React.ReactElement { 7 | return ( 8 | 9 | pageTitle={`Earliest Substitute`} 10 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 11 | getValue={(match) => 12 | match.goalsData 13 | ? match.goalsData.substitutions.find( 14 | (t) => t.team.name === match.team, 15 | )?.time.elapsed ?? "-" 16 | : "-" 17 | } 18 | gridClass={styles.gridExtraWide} 19 | /> 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /types/player-stats.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace PlayerStats { 2 | type ApiResponse = FormGuideAPI.BaseAPI; 3 | type Minutes = { 4 | Rk: number; 5 | Player: string; 6 | Nation: string; 7 | Pos: string; 8 | Squad: string; 9 | Age: string; 10 | Born: number; 11 | MP: number; 12 | Min: number; 13 | "Mn/MP": number; 14 | "Min%": number; 15 | "90s": number; 16 | Starts: number; 17 | "Mn/Start": number; 18 | Compl: number; 19 | Subs: number; 20 | "Mn/Sub": number; 21 | unSub: number; 22 | PPM: number; 23 | onG: number; 24 | onGA: number; 25 | "+/-": number; 26 | "+/-90": number; 27 | "On-Off": number; 28 | onxG: number; 29 | onxGA: number; 30 | "xG+/-": number; 31 | "xG+/-90": number; 32 | Matches: string; 33 | id: string; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "tsBuildInfoFile": ".tsbuildinfo", 18 | "paths": { 19 | "@/components/*": ["components/*"], 20 | "@/constants/*": ["constants/*"], 21 | "@/styles/*": ["styles/*"], 22 | "@/utils/*": ["utils/*"] 23 | }, 24 | "baseUrl": "./" 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 27 | "exclude": ["node_modules", "functions"] 28 | } 29 | -------------------------------------------------------------------------------- /pages/[league]/gd/team-by-half.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function GoalDifference(): React.ReactElement { 4 | return ( 5 | 8 | typeof match.firstHalf !== "undefined" && 9 | typeof match.secondHalf !== "undefined" 10 | ? (match.secondHalf?.goalsScored || 0) - 11 | (match.firstHalf?.goalsScored || 0) - 12 | (match.firstHalf?.goalsScored || 0) 13 | : "-" 14 | } 15 | > 16 | Note: 17 | { 18 | " This is not a super-meaningful chart. It lives mostly as an easy way to see teams that concede more goals in the first half than the second half (negative numbers) or the other way around (positive numbers)" 19 | } 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /pages/[league]/stats/comparison/[type].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { useRouter } from "next/router"; 6 | import { 7 | compareStats, 8 | getStats, 9 | getStatsName, 10 | ValidStats, 11 | } from "@/components/Stats"; 12 | 13 | export default function StatsComparisons(): React.ReactElement { 14 | const router = useRouter(); 15 | const type = String(router.query.type ?? "shots") as ValidStats; 16 | return ( 17 | 18 | pageTitle={`Statistic view: ${getStatsName(type)} compared to opponent`} 19 | getValue={(match) => compareStats(getStats(match, type)) ?? "-"} 20 | getEndpoint={(year, league) => `/api/stats/${league}?year=${year}`} 21 | gridClass={styles.gridExtraWide} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/Table.tsx: -------------------------------------------------------------------------------- 1 | import { getColumns, type Row } from "@/utils/table"; 2 | import { 3 | DataGrid, 4 | type DataGridProps, 5 | type GridColDef, 6 | type GridValidRowModel, 7 | } from "@mui/x-data-grid"; 8 | 9 | export default function Table({ 10 | data, 11 | columns = getColumns, 12 | gridProps = {} as DataGridProps, 13 | }: { 14 | data: ColumnType[]; 15 | columns?: () => GridColDef[]; 16 | gridProps?: Partial>; 17 | }): React.ReactElement { 18 | const { 19 | // eslint-disable-next-line 20 | columns: _columns, 21 | // eslint-disable-next-line 22 | rows: _rows, 23 | ...extraGridProps 24 | } = gridProps; 25 | return ( 26 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | import isLeagueAllowed from "./utils/isLeagueAllowed"; 4 | 5 | export async function middleware(request: NextRequest) { 6 | if ( 7 | request.nextUrl.searchParams.has("league") && 8 | !isLeagueAllowed(String(request.nextUrl.searchParams.get("league"))) 9 | ) { 10 | const url = request.nextUrl.clone(); 11 | 12 | url.pathname = `/`; 13 | return NextResponse.rewrite(url); 14 | } 15 | const response = NextResponse.next(); 16 | 17 | if ( 18 | request.nextUrl.pathname !== "/favicon.ico" && 19 | process.env.NODE_ENV !== "development" 20 | ) { 21 | console.info( 22 | `[${new Date().toJSON()}] ${request.method} ${ 23 | request.nextUrl.pathname 24 | } status:${response.status}`, 25 | ); 26 | } 27 | 28 | return response; 29 | } 30 | -------------------------------------------------------------------------------- /pages/[league]/gd/team-by-half-conceded.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from "@/components/Grid/Base"; 2 | 3 | export default function GoalDifference(): React.ReactElement { 4 | return ( 5 | 8 | typeof match.firstHalf !== "undefined" && 9 | typeof match.secondHalf !== "undefined" 10 | ? (match.secondHalf?.goalsConceded || 0) - 11 | (match.firstHalf?.goalsConceded || 0) - 12 | (match.firstHalf?.goalsConceded || 0) 13 | : "-" 14 | } 15 | > 16 | Note: 17 | { 18 | " This is not a super-meaningful chart. It lives mostly as an easy way to see teams that concede more goals in the first half than the second half (negative numbers) or the other way around (positive numbers)" 19 | } 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /pages/api/fixture/[fixture].ts: -------------------------------------------------------------------------------- 1 | import getFixtureData from "@/utils/api/getFixtureData"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | export default async function form( 5 | req: NextApiRequest, 6 | res: NextApiResponse< 7 | | FormGuideAPI.Responses.FixtureEndpoint 8 | | FormGuideAPI.Responses.ErrorResponse 9 | >, 10 | ): Promise { 11 | const fixture = +String(req.query.fixture); 12 | const { 13 | data, 14 | fromCache: preparedFromCache, 15 | error, 16 | } = await getFixtureData(fixture); 17 | if (error) { 18 | res.status(500); 19 | res.json({ 20 | errors: [{ message: String(error) }], 21 | }); 22 | } 23 | if (data) { 24 | res.setHeader( 25 | `Cache-Control`, 26 | `s-maxage=${60 * 60}, stale-while-revalidate`, 27 | ); 28 | res.json({ 29 | data, 30 | meta: { preparedFromCache }, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/[league]/chart/[period].tsx: -------------------------------------------------------------------------------- 1 | import getMatchPoints from "@/utils/getMatchPoints"; 2 | import BaseRollingPage from "@/components/Rolling/Base"; 3 | import { getArraySum } from "@/utils/array"; 4 | 5 | export default function Chart(): React.ReactElement { 6 | return ( 7 | { 9 | if (value && value / (periodLength * 3) > 0.5) { 10 | return "success.light"; 11 | } 12 | if (value && value / (periodLength * 3) > 0.25) { 13 | return "warning.light"; 14 | } 15 | return "error.light"; 16 | }} 17 | getBoxHeight={(value, periodLength) => 18 | `${((value ?? 0) / (periodLength * 3)) * 100}%` 19 | } 20 | getSummaryValue={getArraySum} 21 | getValue={(match) => getMatchPoints(match)} 22 | pageTitle={`Rolling points (%s game rolling)`} 23 | /> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /pages/api/admin/all-fixtures.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import getRedisClient from "@/utils/redis"; 3 | import { FIXTURE_KEY_PREFIX } from "@/utils/api/getFixtureData"; 4 | 5 | export default async function LoadFixturesEndpoint( 6 | req: NextApiRequest, 7 | res: NextApiResponse< 8 | FormGuideAPI.BaseAPIV2 | FormGuideAPI.Responses.ErrorResponse 9 | >, 10 | ): Promise { 11 | if (process.env.NODE_ENV !== "development") { 12 | res.json({ 13 | errors: [ 14 | { 15 | message: "Incorrect environment to access this endpoint", 16 | }, 17 | ], 18 | }); 19 | return; 20 | } 21 | 22 | const keys = await getRedisClient().scan(`${FIXTURE_KEY_PREFIX}*`); 23 | 24 | res.json({ 25 | data: keys 26 | .map((k) => Number(String(k).match(/\d{6,7}/)?.[0]) ?? 0) 27 | .filter(Boolean), 28 | errors: [], 29 | meta: {}, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pages/**/*.ts" 9 | - "utils/**/*.ts" 10 | - "styles/**/*.ts" 11 | - "types/**/*.ts" 12 | - "**/*.tsx" 13 | - "package-lock.json" 14 | - "package.json" 15 | - "tsconfig.json" 16 | - ".github/workflows/test.yml" 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | paths: 20 | - "pages/**/*.ts" 21 | - "utils/**/*.ts" 22 | - "styles/**/*.ts" 23 | - "types/**/*.ts" 24 | - "**/*.tsx" 25 | - "package-lock.json" 26 | - "package.json" 27 | - ".github/workflows/test.yml" 28 | 29 | jobs: 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: actions/setup-node@v3 35 | - run: npm ci 36 | - run: npm run lint 37 | - run: npm run build 38 | -------------------------------------------------------------------------------- /types/render.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Render { 2 | type RenderReadyData = [string, ...React.ReactElement[]][]; 3 | 4 | type ParserFunction = (T) => Render.RenderReadyData; 5 | 6 | type GenericParserFunction = (data: M) => Render.RenderReadyData; 7 | 8 | type RollingParser< 9 | T = { 10 | value: number | null; 11 | matches: Results.Match[]; 12 | }, 13 | > = ( 14 | data: Results.ParsedData["teams"], 15 | periodLength: number, 16 | homeAway: "home" | "away" | "all", 17 | ) => [string, ...Array][]; 18 | type ASARollingParser< 19 | DataType, 20 | T = { 21 | value: number | null; 22 | matches: Results.Match[]; 23 | }, 24 | > = ( 25 | data: DataType, 26 | periodLength: number, 27 | stat: ASA.ValidStats, 28 | ) => [string, ...Array][]; 29 | 30 | type GetBackgroundColor = ( 31 | value: number | null, 32 | periodLength: number, 33 | ) => string; 34 | } 35 | -------------------------------------------------------------------------------- /pages/[league]/substitutes/earliest-rolling.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useOpponentToggle } from "@/components/Toggle/OpponentToggle"; 4 | import BaseRollingPage from "@/components/Rolling/Base"; 5 | 6 | export default function RollingEarliestSubstitute(): React.ReactElement { 7 | const { value: showOpponent, renderComponent: renderOpponentToggle } = 8 | useOpponentToggle(); 9 | return ( 10 | 11 | max={90} 12 | renderControls={() => <>{renderOpponentToggle()}} 13 | pageTitle={`Earliest Substitute — Rolling %s-game`} 14 | getValue={(match) => 15 | match.goalsData?.substitutions.find( 16 | (e) => 17 | e.team.name === 18 | (showOpponent === "opponent" ? match.opponent : match.team), 19 | )?.time.elapsed 20 | } 21 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 22 | > 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /utils/__tests__/cache-test.ts: -------------------------------------------------------------------------------- 1 | import { compressString, decompressString, getStringSize } from "@/utils/cache"; 2 | import { randomBytes } from "crypto"; 3 | 4 | const generateRandomString = (myLength: number): string => { 5 | return randomBytes(myLength).toString(); 6 | }; 7 | 8 | test("Compress string actually compresses and returns a compressed string", async () => { 9 | const initialValue = generateRandomString(1000000); 10 | const compressedValue = await compressString(initialValue); 11 | 12 | expect(getStringSize(compressedValue)).toBeLessThan( 13 | getStringSize(initialValue), 14 | ); 15 | }); 16 | test("Can compress then decompress", async () => { 17 | // const initialValue = generateRandomString(100); 18 | const initialValue = "a test string!!!!"; 19 | const compressedValue = await compressString(initialValue); 20 | const decompressedValue = await decompressString(compressedValue); 21 | 22 | expect(decompressedValue).toEqual(initialValue); 23 | }); 24 | -------------------------------------------------------------------------------- /pages/[league]/first-goal/[type].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { useRouter } from "next/router"; 6 | import { getFirstGoalConceded, getFirstGoalScored } from "@/utils/getGoals"; 7 | 8 | export default function LostLeads(): React.ReactElement { 9 | const router = useRouter(); 10 | const type = String(router.query.type ?? "gf") as "gf" | "ga"; 11 | return ( 12 | 13 | pageTitle={`First goal ${type === "gf" ? "scored" : "conceded"}`} 14 | getValue={(match) => { 15 | const goal = 16 | type === "gf" 17 | ? getFirstGoalScored(match) 18 | : getFirstGoalConceded(match); 19 | return goal?.time.elapsed ?? "-"; 20 | }} 21 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 22 | gridClass={styles.gridExtraWide} 23 | /> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /pages/[league]/gd-chart/[period].tsx: -------------------------------------------------------------------------------- 1 | import ColorKey from "@/components/ColorKey"; 2 | import BaseRollingPage from "@/components/Rolling/Base"; 3 | import { getArraySum } from "@/utils/array"; 4 | 5 | export default function Chart(): React.ReactElement { 6 | return ( 7 | 12 | (match.goalsScored ?? 0) - (match.goalsConceded ?? 0) 13 | } 14 | getBackgroundColor={({ value }) => 15 | typeof value !== "number" 16 | ? "background.paper" 17 | : value > 0 18 | ? "success.main" 19 | : value === 0 20 | ? "warning.main" 21 | : "error.main" 22 | } 23 | > 24 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | ## daily, create up to three PRs 7 | ## once a week, create up to 10 8 | 9 | version: 2 10 | 11 | updates: 12 | - package-ecosystem: "npm" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | open-pull-requests-limit: 10 15 | schedule: 16 | interval: "daily" 17 | time: "06:00" 18 | timezone: "America/Denver" 19 | - package-ecosystem: "npm" # See documentation for possible values 20 | directory: "/functions" # Location of package manifests 21 | open-pull-requests-limit: 10 22 | schedule: 23 | interval: "daily" 24 | time: "06:00" 25 | timezone: "America/Denver" 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /components/Toggle/PeriodLength.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect } from "react"; 3 | import { useToggle } from "./Toggle"; 4 | 5 | export type PeriodLengthOptions = 3 | 5 | 8 | 11 | number; 6 | 7 | export function usePeriodLength( 8 | defaultValue: PeriodLengthOptions = 5, 9 | withRouter = false, 10 | ) { 11 | const router = useRouter(); 12 | 13 | const toggle = useToggle( 14 | [ 15 | { value: 3, label: 3 }, 16 | { value: 5, label: 5 }, 17 | { value: 8, label: 8 }, 18 | { value: 11, label: 11 }, 19 | ], 20 | defaultValue, 21 | ); 22 | 23 | useEffect(() => { 24 | if ( 25 | withRouter && 26 | !Number.isNaN(Number(router.query.period)) && 27 | toggle.value !== Number(router.query.period) 28 | ) { 29 | router.push({ 30 | pathname: router.pathname, 31 | query: { ...router.query, period: toggle.value }, 32 | }); 33 | } 34 | }, [router, withRouter, toggle.value]); 35 | return toggle; 36 | } 37 | -------------------------------------------------------------------------------- /pages/[league]/gf-chart/[period].tsx: -------------------------------------------------------------------------------- 1 | import ColorKey from "@/components/ColorKey"; 2 | import BaseRollingPage from "@/components/Rolling/Base"; 3 | import { getArraySum } from "@/utils/array"; 4 | 5 | export default function Chart(): React.ReactElement { 6 | return ( 7 | match.goalsScored || 0} 12 | getBackgroundColor={({ value, periodLength }) => 13 | typeof value !== "number" 14 | ? "background.paper" 15 | : value >= periodLength * 2 16 | ? "success.main" 17 | : value >= periodLength 18 | ? "warning.main" 19 | : "error.main" 20 | } 21 | > 22 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /pages/[league]/stats/[type].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { useRouter } from "next/router"; 6 | import { getStats, getStatsName, ValidStats } from "@/components/Stats"; 7 | import { useOpponentToggle } from "@/components/Toggle/OpponentToggle"; 8 | 9 | export default function StatsByMatch(): React.ReactElement { 10 | const router = useRouter(); 11 | const type = String(router.query.type ?? "shots") as ValidStats; 12 | const { renderComponent, value: opponent } = useOpponentToggle(); 13 | return ( 14 | 15 | getEndpoint={(year, league) => `/api/stats/${league}?year=${year}`} 16 | getValue={(match) => 17 | getStats(match, type)[opponent === "opponent" ? 1 : 0] ?? "-" 18 | } 19 | gridClass={styles.gridExtraWide} 20 | pageTitle={`Statistic view: ${getStatsName(type)}`} 21 | renderControls={renderComponent} 22 | > 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /pages/[league]/ga-chart/[period].tsx: -------------------------------------------------------------------------------- 1 | import ColorKey from "@/components/ColorKey"; 2 | import BaseRollingPage from "@/components/Rolling/Base"; 3 | import { getArraySum } from "@/utils/array"; 4 | 5 | export default function Chart(): React.ReactElement { 6 | return ( 7 | match.goalsConceded || 0} 12 | getBackgroundColor={({ value, periodLength }) => 13 | typeof value !== "number" 14 | ? "background.paper" 15 | : value < periodLength * 1.25 16 | ? "success.main" 17 | : value < periodLength * 2 18 | ? "warning.main" 19 | : "error.main" 20 | } 21 | > 22 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /pages/[league]/player-minutes/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import League from "@/components/Context/League"; 3 | import { Box, Link as MLink } from "@mui/material"; 4 | import Link from "next/link"; 5 | import { useContext } from "react"; 6 | 7 | export default function PlayerMinutesBasePage(): React.ReactElement { 8 | const league = useContext(League); 9 | return ( 10 | { 13 | return Object.keys(data.teams) 14 | .sort() 15 | .map((team, idx) => ( 16 | 17 | 18 | {team} 19 | {" "} 20 | •  21 | 22 | Rolling 23 | 24 | 25 | )); 26 | }} 27 | > 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /utils/LeagueCodes.ts: -------------------------------------------------------------------------------- 1 | export const LeagueCodes: Record = { 2 | mls: 253, 3 | nwsl: 254, 4 | mlsnp: 909, 5 | usl1: 489, 6 | usl2: 256, 7 | uslc: 255, 8 | nisa: 523, 9 | epl: 39, 10 | ligamx: 262, 11 | ligamx_ex: 263, 12 | de_bundesliga: 78, 13 | de_2_bundesliga: 79, 14 | de_3_liga: 80, 15 | de_frauen_bundesliga: 82, 16 | sp_la_liga: 140, 17 | sp_segunda: 141, 18 | sp_primera_femenina: 142, 19 | en_championship: 40, 20 | en_league_one: 41, 21 | en_league_two: 42, 22 | en_national: 43, 23 | en_fa_wsl: 44, 24 | fr_ligue_1: 61, 25 | fr_ligue_2: 62, 26 | fr_national_1: 63, 27 | fr_feminine: 64, 28 | it_serie_a: 135, 29 | it_serie_b: 136, 30 | it_serie_a_women: 139, 31 | }; 32 | 33 | export const LeagueCodesInverse: Record = 34 | Object.entries(LeagueCodes) 35 | .map(([league, code]) => ({ 36 | [code]: league as Results.Leagues, 37 | })) 38 | .reduce( 39 | (acc, curr) => ({ 40 | ...acc, 41 | ...curr, 42 | }), 43 | {}, 44 | ); 45 | -------------------------------------------------------------------------------- /utils/getTeamPoints.ts: -------------------------------------------------------------------------------- 1 | import { getArraySum } from "./array"; 2 | import getMatchPoints from "./getMatchPoints"; 3 | 4 | export default function getTeamPoints( 5 | data: Results.ParsedData["teams"], 6 | ): Record< 7 | string, 8 | { date: Date; points: number; result: Results.ResultType; home: boolean }[] 9 | > { 10 | return Object.keys(data).reduce((acc, team) => { 11 | return { 12 | ...acc, 13 | [team]: data[team].map((match) => ({ 14 | date: new Date(match.date), 15 | points: getMatchPoints(match), 16 | result: match.result, 17 | home: match.home, 18 | })), 19 | }; 20 | }, {}); 21 | } 22 | 23 | export function getTeamPointsArray(matches: Results.Match[]): number[] { 24 | return matches.map(getMatchPoints); 25 | } 26 | 27 | export function getCumulativeTeamPointsArray( 28 | matches: Results.Match[], 29 | ): number[] { 30 | const points = matches.map(getMatchPoints); 31 | const cumulativePoints = points.map((_, idx) => { 32 | return getArraySum(points.slice(0, idx)); 33 | }); 34 | return cumulativePoints; 35 | } 36 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | import Script from "next/script"; 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /pages/[league]/points/cumulative.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | import getMatchPoints from "@/utils/getMatchPoints"; 4 | 5 | export default function GoalDifference(): React.ReactElement { 6 | return ; 7 | } 8 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 9 | const cumulative: Record = {}; 10 | return Object.keys(data).map((team) => [ 11 | team, 12 | ...data[team] 13 | .sort((a, b) => { 14 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 15 | }) 16 | .map((match, idx) => { 17 | cumulative[team] = cumulative[team] || []; 18 | cumulative[team][idx] = 19 | (cumulative?.[team]?.[idx - 1] || 0) + getMatchPoints(match); 20 | return ( 21 | cumulative[team][idx]} 25 | /> 26 | ); 27 | }), 28 | ]); 29 | } 30 | -------------------------------------------------------------------------------- /pages/[league]/game-days/since.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | import { differenceInDays } from "date-fns"; 4 | 5 | export default function Home(): React.ReactElement { 6 | return ( 7 | 11 | ); 12 | } 13 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 14 | return Object.keys(data).map((team) => [ 15 | team, 16 | ...data[team] 17 | .sort((a, b) => { 18 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 19 | }) 20 | .map((match, idx) => ( 21 | 25 | typeof data[team][idx - 1]?.date !== "undefined" 26 | ? differenceInDays( 27 | new Date(data[team][idx].date), 28 | new Date(data[team][idx - 1].date), 29 | ) 30 | : "-" 31 | } 32 | /> 33 | )), 34 | ]); 35 | } 36 | -------------------------------------------------------------------------------- /pages/[league]/game-states/lost-leads.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { getExtremeGameState } from "@/utils/gameStates"; 6 | 7 | export default function LostLeads(): React.ReactElement { 8 | return ( 9 | 10 | pageTitle={`Positions Leading to Losses`} 11 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 12 | getShaded={(match) => { 13 | if (match.result === "W") { 14 | return true; 15 | } 16 | const extreme = getExtremeGameState(match, "best"); 17 | return extreme ? extreme[0] <= extreme[1] : true; 18 | }} 19 | gridClass={styles.gridExtraWide} 20 | getValue={(match) => { 21 | if (match.result === "W") { 22 | return "-"; // no lost lead in place 23 | } 24 | const extreme = getExtremeGameState(match, "best"); 25 | return extreme && extreme[0] > extreme[1] ? extreme.join("-") : "-"; 26 | }} 27 | > 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /pages/[league]/gf/cumulative.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | 4 | export default function GoalsFor(): React.ReactElement { 5 | return ( 6 | 7 | ); 8 | } 9 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 10 | const cumulativeGoals: Record = {}; 11 | return Object.keys(data).map((team) => [ 12 | team, 13 | ...data[team] 14 | .sort((a, b) => { 15 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 16 | }) 17 | .map((match, idx) => { 18 | cumulativeGoals[team] = cumulativeGoals[team] || []; 19 | cumulativeGoals[team][idx] = 20 | (cumulativeGoals?.[team]?.[idx - 1] || 0) + 21 | (typeof match.goalsScored === "number" ? match.goalsScored : 0); 22 | return ( 23 | cumulativeGoals[team][idx]} 27 | /> 28 | ); 29 | }), 30 | ]); 31 | } 32 | -------------------------------------------------------------------------------- /pages/[league]/stats/finishing/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { getStats } from "@/components/Stats"; 6 | import { useOpponentToggle } from "@/components/Toggle/OpponentToggle"; 7 | 8 | export default function FinishingStatsByMatch(): React.ReactElement { 9 | const { renderComponent, value: opponent } = useOpponentToggle(); 10 | return ( 11 | 12 | renderControls={renderComponent} 13 | pageTitle={`Statistic view: Finishing Rate`} 14 | getEndpoint={(year, league) => `/api/stats/${league}?year=${year}`} 15 | getValue={(match) => { 16 | const shots = getStats(match, "shots")[opponent === "opponent" ? 1 : 0]; 17 | const goals = 18 | opponent !== "opponent" ? match.goalsScored : match.goalsConceded; 19 | return shots && Number(shots) > 0 20 | ? Number((goals ?? 0) / Number(shots)).toFixed(2) 21 | : "-"; 22 | }} 23 | gridClass={styles.gridExtraWide} 24 | > 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Matt Montgomery 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "dependencies": { 4 | "@google-cloud/functions-framework": "^4.0.1", 5 | "date-fns": "^2.29.3", 6 | "dotenv": "^16.0.3", 7 | "express": "^5.2.1", 8 | "ioredis": "^5.8.2", 9 | "node-fetch-commonjs": "^3.2.4", 10 | "typescript": "^4.8.4" 11 | }, 12 | "devDependencies": { 13 | "@types/express": "^5.0.6", 14 | "@types/node": "^16.11.64", 15 | "@typescript-eslint/eslint-plugin": "^5.39.0" 16 | }, 17 | "scripts": { 18 | "build": "tsc", 19 | "watch": "npx concurrently \"tsc -w\" \"npx nodemon --watch ./build/ --exec npm run start\"", 20 | "start": "functions-framework --source=build/src/ --target=$APPLICATION", 21 | "lint": "npx gts lint", 22 | "clean": "npx gts clean", 23 | "compile": "tsc", 24 | "fix": "npx gts fix", 25 | "test": "tsc --noEmit", 26 | "prepare": "npm run compile", 27 | "pretest": "npm run compile", 28 | "posttest": "npm run lint", 29 | "deploy": "gcloud functions deploy form --trigger-http --runtime nodejs16 --allow-unauthenticated --region us-west3" 30 | }, 31 | "main": "build/src/index.js" 32 | } 33 | -------------------------------------------------------------------------------- /functions/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ENDPOINT = `/v3/fixtures?season=%s&league=%d`; 2 | 3 | export const LeagueCodes: Record = { 4 | mls: 253, 5 | nwsl: 254, 6 | mlsnp: 909, 7 | usl1: 489, 8 | usl2: 256, 9 | uslc: 255, 10 | nisa: 523, 11 | epl: 39, 12 | wsl: 146, 13 | ligamx: 262, 14 | ligamx_ex: 263, 15 | de_bundesliga: 78, 16 | de_2_bundesliga: 79, 17 | de_3_liga: 80, 18 | de_frauen_bundesliga: 82, 19 | sp_la_liga: 140, 20 | sp_segunda: 141, 21 | sp_primera_femenina: 142, 22 | en_championship: 40, 23 | en_league_one: 41, 24 | en_league_two: 42, 25 | en_national: 43, 26 | en_fa_wsl: 44, 27 | fr_ligue_1: 61, 28 | fr_ligue_2: 62, 29 | fr_national_1: 63, 30 | fr_feminine: 64, 31 | it_serie_a: 135, 32 | it_serie_b: 136, 33 | it_serie_a_women: 139, 34 | }; 35 | 36 | export const LeagueCodesInverse: Record = 37 | Object.entries(LeagueCodes) 38 | .map(([league, code]) => ({ 39 | [code]: league as Results.Leagues, 40 | })) 41 | .reduce( 42 | (acc, curr) => ({ 43 | ...acc, 44 | ...curr, 45 | }), 46 | {}, 47 | ); 48 | -------------------------------------------------------------------------------- /pages/[league]/ga/cumulative.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | 4 | export default function GoalsAgainstCumulative(): React.ReactElement { 5 | return ( 6 | 7 | ); 8 | } 9 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 10 | const cumulativeGoals: Record = {}; 11 | return Object.keys(data).map((team) => [ 12 | team, 13 | ...data[team] 14 | .sort((a, b) => { 15 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 16 | }) 17 | .map((match, idx) => { 18 | cumulativeGoals[team] = cumulativeGoals[team] || []; 19 | cumulativeGoals[team][idx] = 20 | (cumulativeGoals?.[team]?.[idx - 1] || 0) + 21 | (typeof match.goalsConceded === "number" ? match.goalsConceded : 0); 22 | return ( 23 | cumulativeGoals[team][idx]} 27 | /> 28 | ); 29 | }), 30 | ]); 31 | } 32 | -------------------------------------------------------------------------------- /pages/[league]/first-goal/rolling/[type]/[period].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | import { getFirstGoalConceded, getFirstGoalScored } from "@/utils/getGoals"; 4 | import BaseRollingPage from "@/components/Rolling/Base"; 5 | 6 | export default function Chart(): React.ReactElement { 7 | const router = useRouter(); 8 | const { type = "gf" } = router.query; 9 | const goalType: "gf" | "ga" = String(type) as "gf" | "ga"; 10 | return ( 11 | 12 | pageTitle={`Rolling first ${type} (%s game rolling)`} 13 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 14 | getBackgroundColor={({ value }) => { 15 | if (goalType === "gf" && value && value > 45) { 16 | return "warning.light"; 17 | } 18 | if (goalType === "ga" && value && value < 45) { 19 | return "warning.light"; 20 | } 21 | return "success.light"; 22 | }} 23 | getBoxHeight={(value) => `${value ? 100 - Math.round(value) : 100}%`} 24 | getValue={(match) => 25 | goalType === "gf" 26 | ? getFirstGoalScored(match)?.time.elapsed 27 | : getFirstGoalConceded(match)?.time.elapsed 28 | } 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /pages/[league]/game-states/comebacks.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { getExtremeGameState } from "@/utils/gameStates"; 6 | 7 | export default function Comebacks(): React.ReactElement { 8 | return ( 9 | 10 | pageTitle={`Positions Leading to Comebacks`} 11 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 12 | getShaded={(match) => { 13 | if (match.result === "L") { 14 | return true; 15 | } 16 | const extreme = getExtremeGameState(match, "worst"); 17 | return extreme ? extreme[0] >= extreme[1] : true; 18 | }} 19 | gridClass={styles.gridExtraWide} 20 | getValue={(match) => { 21 | if (!match.goalsData) { 22 | console.error("Missing", match.fixtureId); 23 | return "X"; 24 | } 25 | if (match.result === "L") { 26 | return "-"; // no comeback in place 27 | } 28 | const extreme = getExtremeGameState(match, "worst"); 29 | return extreme && extreme[0] < extreme[1] ? extreme.join("-") : "-"; 30 | }} 31 | > 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/Fixtures/FixtureListItem.tsx: -------------------------------------------------------------------------------- 1 | import { getRelativeDate } from "@/utils/getFormattedValues"; 2 | import { HourglassBottom, SportsSoccer } from "@mui/icons-material"; 3 | import { 4 | Box, 5 | Button, 6 | ListItem, 7 | ListItemIcon, 8 | ListItemText, 9 | Typography, 10 | } from "@mui/material"; 11 | import Link from "next/link"; 12 | 13 | export default function FixtureListItem( 14 | match: Results.Match, 15 | ): React.ReactElement { 16 | return ( 17 | 18 | 19 | {match.status.long === "Match Finished" ? ( 20 | 21 | ) : ( 22 | 23 | )} 24 | 25 | 26 | 27 | {getRelativeDate(match)} 28 | 29 | 30 | 31 | 32 | 33 | {match.home ? `${match.team}` : match.opponent}{" "} 34 | {match.scoreline || "vs."} {match.home ? match.opponent : match.team} 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /pages/[league]/gd/cumulative.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | 4 | export default function GoalDifference(): React.ReactElement { 5 | return ( 6 | 10 | ); 11 | } 12 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 13 | const cumulativeGoals: Record = {}; 14 | return Object.keys(data).map((team) => [ 15 | team, 16 | ...data[team] 17 | .sort((a, b) => { 18 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 19 | }) 20 | .map((match, idx) => { 21 | cumulativeGoals[team] = cumulativeGoals[team] || []; 22 | cumulativeGoals[team][idx] = 23 | (cumulativeGoals?.[team]?.[idx - 1] || 0) + 24 | (typeof match.goalsConceded === "number" && 25 | typeof match.goalsScored === "number" 26 | ? match.goalsScored - match.goalsConceded 27 | : 0); 28 | return ( 29 | cumulativeGoals[team][idx]} 33 | /> 34 | ); 35 | }), 36 | ]); 37 | } 38 | -------------------------------------------------------------------------------- /pages/[league]/substitutes/most-at-once.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | 6 | function getMaximumSubstitutionsInOneMinuteForTeam( 7 | match: Results.MatchWithGoalData, 8 | team: string, 9 | ) { 10 | const substitutions = match.goalsData?.substitutions.filter( 11 | (e) => e.team.name === team, 12 | ); 13 | if (!substitutions) { 14 | return 0; 15 | } 16 | const times = substitutions.map((e) => e.time.elapsed); 17 | let max = 0; 18 | for (let i = 0; i < times.length; i++) { 19 | let count = 1; 20 | for (let j = i + 1; j < times.length; j++) { 21 | if (times[j] - times[i] <= 1) { 22 | count++; 23 | } 24 | } 25 | max = Math.max(max, count); 26 | } 27 | return max; 28 | } 29 | 30 | export default function EarliestSubstitute(): React.ReactElement { 31 | return ( 32 | 33 | pageTitle={`Most Substitutitions at Once`} 34 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 35 | getValue={(match) => 36 | getMaximumSubstitutionsInOneMinuteForTeam(match, match.team) ?? "-" 37 | } 38 | gridClass={styles.gridExtraWide} 39 | /> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /utils/array.ts: -------------------------------------------------------------------------------- 1 | export function getArraySum(values: number[]): number { 2 | return values.length ? values.reduce((sum, curr) => sum + curr, 0) : 0; 3 | } 4 | 5 | export function getArrayAverageFormatted(values: number[], fixed = 2): string { 6 | const average = getArrayAverage(values); 7 | return (Math.round(average * 100) / 100).toFixed(fixed); 8 | } 9 | 10 | export function getArrayAverage(values: number[]): number { 11 | return values.length 12 | ? values.reduce((sum, curr) => sum + curr, 0) / values.length 13 | : 0; 14 | } 15 | 16 | export function getRecord(values: number[]): string { 17 | return `${values.filter((p) => p === 3).length}-${ 18 | values.filter((p) => p === 1).length 19 | }-${values.filter((p) => p === 0).length}`; 20 | } 21 | export function sortByDate(field: string) { 22 | return (a: Record, b: Record) => { 23 | const dateA = new Date(String(a[field])); 24 | const dateB = new Date(String(b[field])); 25 | return dateA > dateB ? 1 : dateA < dateB ? -1 : 0; 26 | }; 27 | } 28 | 29 | export function chunk(arr: T[], len = 10): T[][] { 30 | const chunks = []; 31 | const n = arr.length; 32 | let i = 0; 33 | 34 | while (i < n) { 35 | chunks.push(arr.slice(i, (i += len))); 36 | } 37 | 38 | return chunks; 39 | } 40 | -------------------------------------------------------------------------------- /pages/[league]/game-days/since-home.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | import { differenceInDays } from "date-fns"; 4 | 5 | export default function Home(): React.ReactElement { 6 | return ( 7 | 11 | ); 12 | } 13 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 14 | return Object.keys(data).map((team) => { 15 | let lastHome: string; 16 | return [ 17 | team, 18 | ...data[team] 19 | .sort((a, b) => { 20 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 21 | }) 22 | .map((match, idx) => { 23 | if (match.home) { 24 | lastHome = match.date; 25 | } else if (idx === 0) { 26 | lastHome = match.date; 27 | } 28 | return ( 29 | 38 | ); 39 | }), 40 | ]; 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /pages/api/admin/load-fixtures.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | const ENDPOINT = process.env.PREDICTIONS_API; 4 | 5 | export default async function LoadFixturesEndpoint( 6 | req: NextApiRequest, 7 | res: NextApiResponse< 8 | FormGuideAPI.BaseAPIV2 | FormGuideAPI.Responses.ErrorResponse 9 | >, 10 | ): Promise { 11 | if (process.env.NODE_ENV !== "development") { 12 | res.json({ 13 | errors: [ 14 | { 15 | message: "Incorrect environment to access this endpoint", 16 | }, 17 | ], 18 | }); 19 | return; 20 | } 21 | const fixtures: string[] = Array.isArray(req.query.fixtureIds) 22 | ? req.query.fixtureIds 23 | : req.query.fixtureIds 24 | ? [...req.query.fixtureIds.split(",")] 25 | : []; 26 | 27 | const responses = []; 28 | 29 | for await (const fixture of fixtures) { 30 | const response = await fetch(`${ENDPOINT}?fixture=${fixture}`); 31 | responses.push(response); 32 | } 33 | 34 | const json = await Promise.all( 35 | responses 36 | .filter((r: Response) => typeof r.json === "function") 37 | .map((r: Response) => r.json()), 38 | ); 39 | res.json({ 40 | data: json.map((match) => match.meta.fixture), 41 | meta: { 42 | fixtures, 43 | }, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /pages/[league]/stats/rolling/[type]/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | import BaseRollingPage from "@/components/Rolling/Base"; 4 | import { 5 | getStats, 6 | getStatsMax, 7 | getStatsName, 8 | ValidStats, 9 | } from "@/components/Stats"; 10 | import { Box } from "@mui/material"; 11 | import { useOpponentToggle } from "@/components/Toggle/OpponentToggle"; 12 | 13 | export default function Chart(): React.ReactElement { 14 | const router = useRouter(); 15 | const { type = "shots" } = router.query; 16 | const statType: ValidStats = String(type) as ValidStats; 17 | const max = getStatsMax(statType); 18 | const { value: showOpponent, renderComponent: renderOpponentToggle } = 19 | useOpponentToggle(); 20 | return ( 21 | 22 | renderControls={() => {renderOpponentToggle()}} 23 | isWide 24 | getEndpoint={(year, league) => `/api/stats/${league}?year=${year}`} 25 | pageTitle={`Rolling ${getStatsName(statType)} (%s game rolling)`} 26 | getBoxHeight={(value) => { 27 | return `${value ? (value / max) * 100 : 0}%`; 28 | }} 29 | getValue={(match) => { 30 | return Number( 31 | getStats(match, statType)[showOpponent === "opponent" ? 1 : 0] ?? 0, 32 | ); 33 | }} 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /utils/getConsecutiveGames.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | 3 | export default function getConsecutiveGames( 4 | results: Results.ParsedData["teams"], 5 | teamNamesSorted: string[], 6 | ): Render.RenderReadyData { 7 | return teamNamesSorted.map((team) => { 8 | const collated = results[team].map((match, idx) => { 9 | const _futureMatches = results[team].map((m, idx) => ({ 10 | matchIdx: idx, 11 | opponentIdx: teamNamesSorted.indexOf(m.opponent), 12 | })); 13 | const __futureMatches = _futureMatches.map((m, idx) => ({ 14 | ...m, 15 | lastOpponentIdx: _futureMatches[idx - 1]?.opponentIdx, 16 | lastOpponentIdxDiff: 17 | m.opponentIdx - _futureMatches[idx - 1]?.opponentIdx, 18 | })); 19 | const futureMatchesIdx = __futureMatches 20 | .slice(idx + 1) 21 | .findIndex((m) => m.lastOpponentIdxDiff !== 1); 22 | return { 23 | ...match, 24 | futureMatches: __futureMatches 25 | .slice(idx + 1) 26 | .slice(0, futureMatchesIdx), 27 | }; 28 | }); 29 | 30 | return [ 31 | team, 32 | ...collated.map((m, idx) => ( 33 | m.futureMatches.length} 37 | /> 38 | )), 39 | ]; 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /pages/[league]/fixtures/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import { List, Typography } from "@mui/material"; 3 | import { isComplete } from "@/utils/match"; 4 | import { sortByDate } from "@/utils/sort"; 5 | import FixtureListItem from "@/components/Fixtures/FixtureListItem"; 6 | 7 | export default function Fixtures(): React.ReactElement { 8 | return ( 9 | { 12 | const fixtures: Results.Match[] = Object.values(data.teams) 13 | .reduce((acc: Results.Match[], matches) => { 14 | return [ 15 | ...acc, 16 | ...matches 17 | .filter((match) => !isComplete(match)) 18 | .filter((match) => { 19 | return !acc.some((m) => m.fixtureId === match.fixtureId); 20 | }), 21 | ].sort(sortByDate); 22 | }, []) 23 | .slice(0, 50); 24 | 25 | return ( 26 | 27 | {fixtures.length === 0 && ( 28 | No unfinished matches 29 | )} 30 | {fixtures.map((match, idx) => ( 31 | 32 | ))} 33 | 34 | ); 35 | }} 36 | /> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /pages/[league]/game-states/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React, { useState } from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | import { FormControlLabel, Switch } from "@mui/material"; 6 | import { getExtremeGameState } from "@/utils/gameStates"; 7 | 8 | export default function GameStates(): React.ReactElement { 9 | const [show, setShow] = useState<"worst" | "best">("best"); 10 | return ( 11 | 12 | renderControls={() => ( 13 | 19 | setShow(ev.currentTarget.checked ? "best" : "worst") 20 | } 21 | /> 22 | } 23 | /> 24 | )} 25 | pageTitle={`${show} game states`} 26 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 27 | getValue={(match) => { 28 | if (!match.goalsData) { 29 | console.error("Missing", match.fixtureId); 30 | return "X"; 31 | } 32 | const extreme = getExtremeGameState(match, show); 33 | return extreme ? extreme.join("-") : "-"; 34 | }} 35 | gridClass={styles.gridExtraWide} 36 | /> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /components/Rolling/BoxV2.tsx: -------------------------------------------------------------------------------- 1 | import { format, parseISO } from "date-fns"; 2 | import MatchDescriptor from "../MatchDescriptor"; 3 | import AbstractRollingBox from "./AbstractBox"; 4 | 5 | export type NumberFormat = (value: number | null) => string; 6 | 7 | export type RollingBoxProps = { 8 | backgroundColor: string; 9 | boxHeight: string; 10 | matches?: T[]; 11 | numberFormat?: NumberFormat; 12 | value: number | null; 13 | }; 14 | 15 | export default function RollingBoxV2({ 16 | backgroundColor, 17 | boxHeight, 18 | matches = [], 19 | value, 20 | numberFormat = (value: number | null): string => 21 | typeof value === "number" 22 | ? Number.isInteger(value) 23 | ? value.toString() 24 | : value?.toFixed(1) 25 | : "", 26 | }: RollingBoxProps): React.ReactElement { 27 | return ( 28 | ( 32 |
    33 | {matches.map((match, idx) => ( 34 |
  1. 35 | {format(parseISO(match.rawDate), "yyy-MM-dd")} 36 | : 37 |
  2. 38 | ))} 39 |
40 | )} 41 | numberFormat={numberFormat} 42 | value={value} 43 | /> 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /components/ColorKey.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Card, 4 | CardContent, 5 | CardMedia, 6 | Grid, 7 | Typography, 8 | } from "@mui/material"; 9 | 10 | export default function ColorKey({ 11 | successText, 12 | warningText, 13 | errorText, 14 | }: { 15 | successText: React.ReactNode; 16 | warningText: React.ReactNode; 17 | errorText: React.ReactNode; 18 | }): React.ReactElement { 19 | return ( 20 | 21 | Legend 22 | 23 | 24 | 25 | 28 | {successText} 29 | 30 | 31 | 32 | 33 | 36 | {warningText} 37 | 38 | 39 | 40 | 41 | 44 | {errorText} 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /pages/[league]/ppg/team.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import getTeamPoints from "@/utils/getTeamPoints"; 4 | import BasePage from "@/components/BaseGridPage"; 5 | import { getArrayAverageFormatted } from "@/utils/array"; 6 | 7 | export default function PPGTeam(): React.ReactElement { 8 | return ( 9 | 14 | ); 15 | } 16 | 17 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 18 | const teamPoints = getTeamPoints(data); 19 | return Object.keys(data).map((team) => [ 20 | team, 21 | ...data[team] 22 | .sort((a, b) => { 23 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 24 | }) 25 | .map((match, idx) => ( 26 | { 30 | const points = teamPoints[team] 31 | .filter( 32 | (opponentMatch) => 33 | opponentMatch.date < new Date(match.date) && 34 | opponentMatch.result !== null, 35 | ) 36 | .map((opponentPoints) => opponentPoints.points); 37 | return getArrayAverageFormatted(points); 38 | }} 39 | /> 40 | )), 41 | ]); 42 | } 43 | -------------------------------------------------------------------------------- /components/App/LeagueSelect.tsx: -------------------------------------------------------------------------------- 1 | import { LeagueOptions } from "@/utils/Leagues"; 2 | import { Autocomplete, TextField } from "@mui/material"; 3 | import { useRouter } from "next/router"; 4 | import { NavProps } from "../Nav"; 5 | 6 | export default function LeagueSelect({ 7 | league, 8 | onSetLeague, 9 | }: { 10 | league: Results.Leagues; 11 | onSetLeague: NavProps["onSetLeague"]; 12 | }) { 13 | const router = useRouter(); 14 | return ( 15 | ({ 17 | label: name, 18 | id: league, 19 | }))} 20 | sx={{ 21 | width: "100%", 22 | }} 23 | renderInput={(params) => ( 24 | 33 | )} 34 | value={{ id: league, label: LeagueOptions[league] }} 35 | onChange={(_, newValue) => { 36 | if (newValue) { 37 | onSetLeague(String(newValue.id) as Results.Leagues); 38 | router.push({ 39 | pathname: router.pathname, 40 | query: { 41 | ...router.query, 42 | league: String(newValue.id), 43 | }, 44 | }); 45 | } 46 | }} 47 | /> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /components/KBar/Input.tsx: -------------------------------------------------------------------------------- 1 | import { SearchSharp } from "@mui/icons-material"; 2 | import { Input, InputBaseComponentProps } from "@mui/material"; 3 | import { KBarSearch } from "@refinedev/kbar"; 4 | import { forwardRef, useContext } from "react"; 5 | import DarkMode from "../Context/DarkMode"; 6 | 7 | export default function KBarInput() { 8 | const darkMode = useContext(DarkMode); 9 | return ( 10 | } 12 | sx={{ 13 | width: "100%", 14 | paddingLeft: 1, 15 | borderRadius: "0.25rem", 16 | backgroundColor: darkMode ? "rgb(30,60,90)" : "rgba(255,255,255,0.9)", 17 | }} 18 | inputComponent={ReffedSearchBar} 19 | /> 20 | ); 21 | } 22 | 23 | function SearchBar(props: InputBaseComponentProps): React.ReactElement { 24 | const darkMode = useContext(DarkMode); 25 | return ( 26 | 39 | ); 40 | } 41 | 42 | // eslint-disable-next-line react/display-name 43 | const ReffedSearchBar = forwardRef((props: InputBaseComponentProps) => ( 44 | 45 | )); 46 | -------------------------------------------------------------------------------- /pages/[league]/versus/gd.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import VersusGrid from "@/components/VersusGrid"; 3 | import { getArrayAverage, getArraySum } from "@/utils/array"; 4 | 5 | export default function VersusPoints(): React.ReactElement { 6 | return ( 7 | ( 10 | `${values.length} / ${getArraySum(values)}`} 13 | getValue={(result) => result.gd || 0} 14 | getBackgroundColor={(points) => { 15 | if (!points || points.length === 0) { 16 | return "transparent"; 17 | } 18 | const avg = getArrayAverage(points); 19 | return avg >= 1 20 | ? "success.main" 21 | : avg >= 0 22 | ? "warning.main" 23 | : "error.main"; 24 | }} 25 | getForegroundColor={(points) => { 26 | if (!points || points.length === 0) { 27 | return "text.primary"; 28 | } 29 | const avg = getArrayAverage(points); 30 | return avg >= 1 31 | ? "success.contrastText" 32 | : avg >= 0 33 | ? "warning.contrastText" 34 | : "error.contrastText"; 35 | }} 36 | /> 37 | )} 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /pages/[league]/versus/record.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import VersusGrid from "@/components/VersusGrid"; 3 | import { getArrayAverage, getRecord } from "@/utils/array"; 4 | import getMatchPoints from "@/utils/getMatchPoints"; 5 | 6 | export default function VersusPoints(): React.ReactElement { 7 | return ( 8 | ( 11 | { 16 | if (!points || points.length === 0) { 17 | return "transparent"; 18 | } 19 | const avg = getArrayAverage(points); 20 | return avg >= 2 21 | ? "success.main" 22 | : avg >= 1 23 | ? "warning.main" 24 | : "error.main"; 25 | }} 26 | getForegroundColor={(points) => { 27 | if (!points || points.length === 0) { 28 | return "text.primary"; 29 | } 30 | const avg = getArrayAverage(points); 31 | return avg >= 2 32 | ? "success.contrastText" 33 | : avg >= 1 34 | ? "warning.contrastText" 35 | : "error.contrastText"; 36 | }} 37 | /> 38 | )} 39 | /> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /pages/[league]/substitutes/earliest-multiple.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/Grid/Base"; 2 | import React from "react"; 3 | 4 | import styles from "@/styles/Home.module.css"; 5 | 6 | function getMaximumSubstitutionsInOneMinuteForTeam( 7 | match: Results.MatchWithGoalData, 8 | team: string, 9 | ) { 10 | const substitutions = match.goalsData?.substitutions.filter( 11 | (e) => e.team.name === team, 12 | ); 13 | if (!substitutions) { 14 | return 0; 15 | } 16 | const times = substitutions.map((e) => e.time.elapsed); 17 | let earliestMultiple: number | undefined = undefined; 18 | for (let i = 0; i < times.length; i++) { 19 | let count = 1; 20 | for (let j = i + 1; j < times.length; j++) { 21 | if (times[j] - times[i] <= 1) { 22 | count++; 23 | } 24 | } 25 | if ( 26 | count > 1 && 27 | (earliestMultiple === undefined || times[i] < earliestMultiple) 28 | ) { 29 | earliestMultiple = times[i]; 30 | } 31 | } 32 | return earliestMultiple; 33 | } 34 | 35 | export default function EarliestSubstitute(): React.ReactElement { 36 | return ( 37 | 38 | pageTitle={`Earliest Substitute`} 39 | getEndpoint={(year, league) => `/api/goals/${league}?year=${year}`} 40 | getValue={(match) => 41 | getMaximumSubstitutionsInOneMinuteForTeam(match, match.team) ?? "-" 42 | } 43 | gridClass={styles.gridExtraWide} 44 | /> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /pages/[league]/xg/for.tsx: -------------------------------------------------------------------------------- 1 | import BaseASAGridPage from "@/components/BaseASAGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { format } from "util"; 4 | import styles from "@/styles/Home.module.css"; 5 | import { transformXGMatchIntoASAMatch } from "@/utils/transform"; 6 | 7 | export default function XGForm() { 8 | return ( 9 | 10 | gridClass={styles.gridExtraWide} 11 | dataParser={(data) => { 12 | return Object.keys(data.xg).map((team, teamIdx) => { 13 | const teamData = data.xg[team]; 14 | return [ 15 | team, 16 | ...teamData.map((match, idx) => ( 17 | 20 | match.isHome 21 | ? Number(match.home_player_xgoals).toFixed(3) 22 | : Number(match.away_player_xgoals).toFixed(3) 23 | } 24 | match={transformXGMatchIntoASAMatch(match)} 25 | /> 26 | )), 27 | ]; 28 | }); 29 | }} 30 | pageTitle="XG For" 31 | endpoint={(year, league) => 32 | format(`/api/asa/xg?year=%d&league=%s`, year, league) 33 | } 34 | > 35 | Data via{" "} 36 | 37 | American Soccer Analysis API 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /pages/[league]/xg/against.tsx: -------------------------------------------------------------------------------- 1 | import BaseASAGridPage from "@/components/BaseASAGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { format } from "util"; 4 | import styles from "@/styles/Home.module.css"; 5 | import { transformXGMatchIntoASAMatch } from "@/utils/transform"; 6 | 7 | export default function XGForm() { 8 | return ( 9 | 10 | gridClass={styles.gridExtraWide} 11 | dataParser={(data) => { 12 | return Object.keys(data.xg).map((team, teamIdx) => { 13 | const teamData = data.xg[team]; 14 | return [ 15 | team, 16 | ...teamData.map((match, idx) => ( 17 | 20 | match.isHome 21 | ? Number(match.away_player_xgoals).toFixed(3) 22 | : Number(match.home_player_xgoals).toFixed(3) 23 | } 24 | match={transformXGMatchIntoASAMatch(match)} 25 | /> 26 | )), 27 | ]; 28 | }); 29 | }} 30 | pageTitle="XG Against" 31 | endpoint={(year, league) => 32 | format(`/api/asa/xg?year=%d&league=%s`, year, league) 33 | } 34 | > 35 | Data via{" "} 36 | 37 | American Soccer Analysis API 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /pages/[league]/fixtures/today.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import { List, Typography } from "@mui/material"; 3 | import { isComplete } from "@/utils/match"; 4 | import { sortByDate } from "@/utils/sort"; 5 | import FixtureListItem from "@/components/Fixtures/FixtureListItem"; 6 | import { isToday, parseISO } from "date-fns"; 7 | 8 | export default function Fixtures(): React.ReactElement { 9 | return ( 10 | { 13 | const fixtures: Results.Match[] = Object.values(data.teams) 14 | .reduce((acc: Results.Match[], matches) => { 15 | return [ 16 | ...acc, 17 | ...matches 18 | .filter((match) => !isComplete(match)) 19 | .filter((match) => isToday(parseISO(match.rawDate))) 20 | .filter((match) => { 21 | return !acc.some((m) => m.fixtureId === match.fixtureId); 22 | }), 23 | ].sort(sortByDate); 24 | }, []) 25 | .slice(0, 50); 26 | 27 | return ( 28 | 29 | {fixtures.length === 0 && ( 30 | No matches today 31 | )} 32 | {fixtures.map((match, idx) => ( 33 | 34 | ))} 35 | 36 | ); 37 | }} 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /pages/[league]/versus/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import VersusGrid from "@/components/VersusGrid"; 3 | import { getArrayAverage, getArrayAverageFormatted } from "@/utils/array"; 4 | import getMatchPoints from "@/utils/getMatchPoints"; 5 | 6 | export default function VersusPoints(): React.ReactElement { 7 | return ( 8 | ( 11 | 14 | `${values.length} / ${getArrayAverageFormatted(values, 1)}` 15 | } 16 | getValue={getMatchPoints} 17 | getBackgroundColor={(points) => { 18 | if (!points || points.length === 0) { 19 | return "transparent"; 20 | } 21 | const avg = getArrayAverage(points); 22 | return avg >= 2 23 | ? "success.main" 24 | : avg >= 1 25 | ? "warning.main" 26 | : "error.main"; 27 | }} 28 | getForegroundColor={(points) => { 29 | if (!points || points.length === 0) { 30 | return "text.primary"; 31 | } 32 | const avg = getArrayAverage(points); 33 | return avg >= 2 34 | ? "success.contrastText" 35 | : avg >= 1 36 | ? "warning.contrastText" 37 | : "error.contrastText"; 38 | }} 39 | /> 40 | )} 41 | /> 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /pages/api/form.ts: -------------------------------------------------------------------------------- 1 | import getExpires from "@/utils/getExpires"; 2 | import { getCurrentYear, LeagueYearOffset } from "@/utils/Leagues"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | 5 | const FORM_API = process.env.FORM_API; 6 | 7 | export default async function form( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | ): Promise { 11 | const league = String(req.query.league) as Results.Leagues; 12 | const year = +String(req.query.year) || getCurrentYear(league); 13 | const yearOffset = LeagueYearOffset[league] ?? 0; 14 | const args = `year=${year + yearOffset}&league=${league || "mls"}`; 15 | if (!FORM_API) { 16 | console.error({ error: "Missing environment variables" }); 17 | res.status(500); 18 | res.json({ 19 | data: {}, 20 | errors: ["Application not properly configured"], 21 | }); 22 | return; 23 | } 24 | try { 25 | const response = await fetch(`${FORM_API}?${args}`); 26 | res.setHeader( 27 | `Cache-Control`, 28 | `s-maxage=${getExpires(year)}, stale-while-revalidate`, 29 | ); 30 | if (response.status !== 200) { 31 | throw `function response: ${response.statusText}`; 32 | } 33 | const responseBody = await response.json(); 34 | res.json({ 35 | ...responseBody, 36 | meta: { ...(responseBody.meta || {}), year, league, args }, 37 | }); 38 | } catch (e) { 39 | console.error(JSON.stringify({ error: e })); 40 | res.status(500); 41 | res.json({ 42 | data: {}, 43 | errors: [String(e)], 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/gcp-functions-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy GCP cloud function 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "functions/src/**.ts" 9 | - "functions/package-lock.json" 10 | - "functions/package.json" 11 | - ".github/workflows/gcp-functions-deploy.yml" 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | defaults: 17 | run: 18 | working-directory: functions 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: 20 24 | - run: npm ci 25 | - id: "auth" 26 | uses: "google-github-actions/auth@v1" 27 | with: 28 | credentials_json: "${{ secrets.GCP_CREDENTIALS }}" 29 | 30 | - id: "deploy-form" 31 | uses: "google-github-actions/deploy-cloud-functions@v1" 32 | with: 33 | name: "form" 34 | runtime: "nodejs20" 35 | region: "us-west3" 36 | source_dir: functions 37 | env_vars: REDIS_URL=${{secrets.REDIS_URL}},API_FOOTBALL_BASE=${{secrets.API_FOOTBALL_BASE}},API_FOOTBALL_KEY=${{secrets.API_FOOTBALL_KEY}} 38 | 39 | - id: "deploy-prediction" 40 | uses: "google-github-actions/deploy-cloud-functions@v1" 41 | with: 42 | name: "prediction" 43 | runtime: "nodejs20" 44 | region: "us-west3" 45 | source_dir: functions 46 | env_vars: REDIS_URL=${{secrets.REDIS_URL}},API_FOOTBALL_BASE=${{secrets.API_FOOTBALL_BASE}},API_FOOTBALL_KEY=${{secrets.API_FOOTBALL_KEY}} 47 | -------------------------------------------------------------------------------- /components/DateFilter.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { format, isValid, parseISO } from "date-fns"; 4 | import { useState } from "react"; 5 | 6 | export default function DateFilter({ 7 | from, 8 | to, 9 | setFrom, 10 | setTo, 11 | }: { 12 | from: Date; 13 | to: Date; 14 | setFrom: (d: Date) => void; 15 | setTo: (d: Date) => void; 16 | }) { 17 | return ( 18 | 19 | From:{" "} 20 | { 24 | const newDate = parseISO(ev.currentTarget.value); 25 | if (isValid(newDate)) setFrom(newDate); 26 | }} 27 | /> 28 | To:{" "} 29 | { 33 | const newDate = parseISO(ev.currentTarget.value); 34 | if (isValid(newDate)) setTo(newDate); 35 | }} 36 | /> 37 | 38 | ); 39 | } 40 | 41 | export function useDateFilter( 42 | defaultFrom: Date, 43 | defaultTo: Date, 44 | ): { 45 | from: Date; 46 | to: Date; 47 | setFrom: (date: Date) => void; 48 | setTo: (date: Date) => void; 49 | renderComponent: () => React.ReactNode; 50 | } { 51 | const [from, setFrom] = useState(defaultFrom); 52 | const [to, setTo] = useState(defaultTo); 53 | const renderComponent = () => ( 54 | 55 | ); 56 | return { from, to, setFrom, setTo, renderComponent }; 57 | } 58 | -------------------------------------------------------------------------------- /functions/src/football-api.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch-commonjs"; 2 | import { config } from "dotenv"; 3 | 4 | config(); 5 | 6 | const URL_BASE = `https://${process.env.API_FOOTBALL_BASE}`; 7 | const REDIS_URL = process.env.REDIS_URL; 8 | const API_BASE = process.env.API_FOOTBALL_BASE; 9 | const API_KEY = process.env.API_FOOTBALL_KEY; 10 | 11 | export async function getFixture( 12 | fixtureId: number, 13 | ): Promise { 14 | const authHeaders = await getAuthenticationHeaders(); 15 | const resp = await fetch(`${URL_BASE}/v3/fixtures?id=${fixtureId}`, { 16 | headers: authHeaders, 17 | }); 18 | return ((await resp.json()) as { response: Results.FixtureApi[] })?.response; 19 | } 20 | export async function getPredictionsForFixture( 21 | fixtureId: number, 22 | ): Promise { 23 | const authHeaders = await getAuthenticationHeaders(); 24 | const resp = await fetch(`${URL_BASE}/v3/predictions?fixture=${fixtureId}`, { 25 | headers: authHeaders, 26 | }); 27 | return ((await resp.json()) as { response: Results.PredictionApi[] }) 28 | ?.response; 29 | } 30 | 31 | async function getAuthenticationHeaders(): Promise<{ 32 | "x-rapidapi-host": string; 33 | "x-rapidapi-key": string; 34 | useQueryString: string; 35 | }> { 36 | if ( 37 | typeof REDIS_URL !== "string" || 38 | typeof API_BASE !== "string" || 39 | typeof API_KEY !== "string" 40 | ) { 41 | throw "Application not properly configured"; 42 | } 43 | return { 44 | "x-rapidapi-host": API_BASE, 45 | "x-rapidapi-key": API_KEY, 46 | useQueryString: "true", 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form Guide 2 | 3 | Do you remember a day when MLS hosted their own form guide? I do. I missed it, so I built it, and hopefully, I've made it better. 4 | 5 | I've also added some more leagues. Fun! 6 | 7 | ## Development 8 | 9 | ### Front-end application 10 | 11 | - Powered by Node.js, minimum of 16.x. 12 | - Built with Next.js 13 | - Hosted in production on Vercel 14 | - Uses Husky for precommit and commit message linting 15 | - Two sources of data: 16 | - Most raw data is sourced from API-FOOTBALL on Rapid API, uses proxy API noted below in **Back-end application**. 17 | - XG data is sourced from American Soccer Analysis's open API 18 | 19 | ### Back-end application 20 | 21 | - Powered by Node.js, minimum of 16.x 22 | - Hosted in Google Cloud Functions 23 | - Sources data from API-FOOTBALL on Rapid API. 24 | - Two functions: [Form](./functions/src/form.ts) and [Prediction](./functions/src/prediction.ts) 25 | 26 | ### Development Setup 27 | 28 | - Register with API-FOOTBALL (I use their free tier) and set the proper values in a [`.env file`](./functions.env.default) 29 | - Run the Google Cloud Functions locally 30 | - `APPLICATION={APP_NAME} npm start`, where APP_NAME is one of `form` and `prediction` 31 | - Create a `.env` file with values shown in [`.env.default`](./.env.default). 32 | - Reference where you have those Google Cloud Functions running in those environment variables. 33 | 34 | ## Contributing 35 | 36 | Want to contribute? Check out [the contribution guidelines](./CONTRIBUTING.md). 37 | 38 | ### Contributors 39 | 40 | - Matt Montgomery — [Twitter/@TheCrossbarRSL](https://twitter.com/TheCrossbarRSL) 41 | -------------------------------------------------------------------------------- /pages/api/player-stats/minutes.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { parse } from "csv"; 4 | 5 | import { readFile } from "node:fs/promises"; 6 | import path from "path"; 7 | 8 | export default async function playerStats( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ): Promise { 12 | return new Promise(async (resolve, reject) => { 13 | const csvFilePath = path.resolve("mls-data/", "20220830_playerStats.csv"); 14 | 15 | const parser = parse({ 16 | columns: true, 17 | cast: true, 18 | }); 19 | try { 20 | const csvData = await readFile(csvFilePath, "utf8"); 21 | parser.write(csvData, "utf8", (e) => { 22 | if (e) { 23 | console.error(e); 24 | res.json({ 25 | errors: [{ message: e }], 26 | }); 27 | 28 | throw e; 29 | } 30 | }); 31 | } catch (e) { 32 | console.error(e); 33 | res.json({ 34 | errors: [{ message: e }], 35 | }); 36 | reject(); 37 | return; 38 | } 39 | parser.end(); 40 | const records: unknown[] = []; 41 | parser.on("readable", () => { 42 | let record; 43 | while ((record = parser.read()) !== null) { 44 | records.push(record); 45 | } 46 | }); 47 | parser.on("error", function (err) { 48 | console.error(err.message); 49 | res.json({ 50 | errors: [{ message: err.message }], 51 | }); 52 | reject(); 53 | }); 54 | parser.on("end", () => { 55 | res.json({ 56 | data: records, 57 | }); 58 | resolve(); 59 | }); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /utils/transform.ts: -------------------------------------------------------------------------------- 1 | import { LeagueCodes } from "@/utils/LeagueCodes"; 2 | import { parseJSON } from "date-fns"; 3 | 4 | export function transformXGMatchIntoASAMatch( 5 | match: ASA.XGWithGame & ASA.HomeAway, 6 | ): Results.Match { 7 | return { 8 | date: match.date_time_utc, 9 | rawDate: parseJSON(match.date_time_utc).toISOString(), 10 | fixtureId: -1, 11 | home: match.isHome, 12 | opponent: match.isHome ? match.awayTeam : match.homeTeam, 13 | status: { 14 | short: "ft", 15 | elapsed: 90, 16 | long: "Match Finished", 17 | }, 18 | opponentLogo: "", 19 | result: match.isHome 20 | ? match.home_goals > match.away_goals 21 | ? "W" 22 | : match.home_goals < match.away_goals 23 | ? "L" 24 | : "D" 25 | : match.away_goals > match.home_goals 26 | ? "W" 27 | : match.home_goals === match.away_goals 28 | ? "D" 29 | : "L", 30 | 31 | score: { 32 | halftime: { 33 | away: 0, 34 | home: 0, 35 | }, 36 | extratime: { 37 | away: 0, 38 | home: 0, 39 | }, 40 | penalty: { 41 | away: 0, 42 | home: 0, 43 | }, 44 | fulltime: { 45 | away: match.away_goals, 46 | home: match.home_goals, 47 | }, 48 | }, 49 | scoreline: `${match.home_goals}-${match.away_goals}`, 50 | team: match.isHome ? match.homeTeam : match.awayTeam, 51 | league: { 52 | country: "USA", 53 | flag: "", 54 | id: LeagueCodes.mls, 55 | logo: "", 56 | name: "Major League Soccer", 57 | season: -1, 58 | }, 59 | asa: match, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /pages/api/theodds/[league].ts: -------------------------------------------------------------------------------- 1 | import { fetchCachedOrFresh } from "@/utils/cache"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | const TheOddsMapping: Partial> = { 5 | mls: "soccer_usa_mls", 6 | epl: "soccer_epl", 7 | ligamx: "soccer_mexico_ligamx", 8 | }; 9 | 10 | const getEndpoint = ( 11 | sport: string, 12 | regions: "us" | "uk" | "au" | "eu" = "us", 13 | markets: ("h2h" | "spreads" | "totals" | "outrights")[] = [ 14 | "h2h", 15 | "spreads", 16 | "totals", 17 | ], 18 | ) => 19 | `https://api.the-odds-api.com/v4/sports/${sport}/odds/?apiKey=${ 20 | process.env.THEODDS_API_KEY 21 | }®ions=${regions}&markets=${markets.join(",")}`; 22 | 23 | export default async function LeagueOdds( 24 | req: NextApiRequest, 25 | res: NextApiResponse< 26 | | FormGuideAPI.BaseAPIV2 27 | | FormGuideAPI.Responses.ErrorResponse 28 | >, 29 | ): Promise { 30 | const league = String(req.query.league) as Results.Leagues; 31 | if (typeof TheOddsMapping[league] !== "string") { 32 | res.json({ 33 | errors: [{ message: "League not supported" }], 34 | }); 35 | return; 36 | } 37 | const leagueCode = TheOddsMapping[league] ?? `${TheOddsMapping["mls"]}`; 38 | 39 | const data = await fetchCachedOrFresh( 40 | `odds:${leagueCode}:v1`, 41 | async () => { 42 | const endpoint = getEndpoint(leagueCode); 43 | const res = await fetch(endpoint); 44 | return res.json(); 45 | }, 46 | 60 * 60 * 1, // cache for 1 hour 47 | ); 48 | res.json({ 49 | data: data, 50 | errors: [], 51 | meta: { 52 | leagueCode, 53 | }, 54 | }); 55 | return; 56 | } 57 | -------------------------------------------------------------------------------- /components/Selector/Stats.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import { useRouter } from "next/router"; 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { stats, ValidStats } from "../Stats"; 5 | 6 | export function useStatsToggle({ 7 | selected = [], 8 | routerField = "type", 9 | }: { 10 | selected: ValidStats[]; 11 | routerField?: string; 12 | }) { 13 | const [statTypes, setStatTypes] = useState(selected); 14 | const router = useRouter(); 15 | useEffect(() => { 16 | if (router.query[routerField] !== statTypes.join(",")) { 17 | router.push({ 18 | pathname: router.pathname, 19 | query: { ...router.query, [routerField]: statTypes.join(",") }, 20 | }); 21 | } 22 | }, [router, routerField, statTypes]); 23 | const renderComponent = useCallback( 24 | () => ( 25 | 48 | ), 49 | [statTypes], 50 | ); 51 | return { 52 | value: statTypes, 53 | renderComponent, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /components/BaseASADataPage.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import BasePage, { BasePageProps } from "./BasePage"; 3 | import { Box, CircularProgress, Divider } from "@mui/material"; 4 | import YearContext from "./Context/Year"; 5 | import LeagueContext from "./Context/League"; 6 | import { useContext } from "react"; 7 | 8 | const fetcher = (url: string) => fetch(url).then((r) => r.json()); 9 | function BaseASADataPage< 10 | T = ASA.GenericApi["data"], 11 | U = ASA.GenericApi["meta"], 12 | >({ 13 | children, 14 | renderControls, 15 | renderComponent, 16 | pageTitle, 17 | endpoint = (year, league) => `/api/asa/xg?year=${year}&league=${league}`, 18 | }: { 19 | renderControls?: BasePageProps["renderControls"]; 20 | renderComponent: (data: T, meta: U) => React.ReactNode; 21 | pageTitle: string; 22 | children?: React.ReactNode; 23 | endpoint: ASA.Endpoint; 24 | }): React.ReactElement { 25 | const year = useContext(YearContext); 26 | const league = useContext(LeagueContext); 27 | const { data } = useSWR<{ 28 | data: T; 29 | meta: U; 30 | }>(endpoint(String(year), league), fetcher); 31 | return ( 32 | 33 | {data && data?.data ? ( 34 | <> 35 | {renderComponent(data.data, data.meta)} 36 | 37 | {children} 38 | 39 | ) : ( 40 | 46 | 47 | 48 | )} 49 | 50 | ); 51 | } 52 | export default BaseASADataPage; 53 | -------------------------------------------------------------------------------- /utils/getFormattedValues.ts: -------------------------------------------------------------------------------- 1 | import { format, formatRelative, isThisWeek, parseISO } from "date-fns"; 2 | 3 | export function getFormattedDate( 4 | match: Results.Fixture | Results.Match, 5 | showTime = true, 6 | ): string { 7 | const date = (match as Results.Match).rawDate 8 | ? (match as Results.Match).rawDate 9 | : match.date; 10 | return typeof date === "string" 11 | ? format(parseISO(date), `eee., MMM d, Y${showTime ? ", K:mm aaaa" : ""}`) 12 | : ""; 13 | } 14 | export function getFormattedTime( 15 | match: Results.Fixture | Results.Match, 16 | ): string { 17 | const date = (match as Results.Match).rawDate 18 | ? (match as Results.Match).rawDate 19 | : match.date; 20 | return typeof date === "string" ? format(parseISO(date), "K:mm aaaa z") : ""; 21 | } 22 | 23 | export function getFormattedEventName(event: Results.FixtureEvent): string { 24 | if (event.type === "subst") { 25 | return event.detail; 26 | } else if (event.type === "Card") { 27 | return event.detail; 28 | } else { 29 | return event.type; 30 | } 31 | } 32 | 33 | export function getMatchTitle(match: Results.Match) { 34 | return `${match.home ? match.team : match.opponent} ${ 35 | match.scoreline ?? "vs." 36 | } ${match.home ? match.opponent : match.team}`; 37 | } 38 | 39 | export function getRelativeDate( 40 | match: Results.Fixture | Results.Match, 41 | showTime = true, 42 | ): string { 43 | const date = (match as Results.Match).rawDate 44 | ? (match as Results.Match).rawDate 45 | : match.date; 46 | const d = parseISO(date); 47 | if (isThisWeek(d)) { 48 | return formatRelative(d, new Date()); 49 | } else { 50 | return getFormattedDate(match, showTime); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /components/EasterEgg.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import EasterEggContext from "./Context/EasterEgg"; 3 | 4 | export const KONAMI_CODE = [ 5 | "ArrowUp", 6 | "ArrowUp", 7 | "ArrowDown", 8 | "ArrowDown", 9 | "ArrowLeft", 10 | "ArrowRight", 11 | "ArrowLeft", 12 | "ArrowRight", 13 | "b", 14 | "a", 15 | "Enter", 16 | ]; 17 | 18 | export default function EasterEgg({ 19 | easterEgg = false, 20 | onSetEasterEgg, 21 | }: { 22 | easterEgg: boolean; 23 | onSetEasterEgg: (state: boolean) => void; 24 | }) { 25 | const [konamiCode, setKonamiCode] = useState([]); 26 | const listener = useCallback( 27 | (ev: KeyboardEvent) => { 28 | if (easterEgg) { 29 | return; 30 | } 31 | if (KONAMI_CODE[konamiCode.length] === ev.key) { 32 | const newKonamiCode = [...konamiCode, ev.key]; 33 | setKonamiCode(newKonamiCode); 34 | if (KONAMI_CODE.length === newKonamiCode.length) { 35 | onSetEasterEgg(true); 36 | } 37 | } else { 38 | setKonamiCode([]); 39 | } 40 | }, 41 | [konamiCode, easterEgg, onSetEasterEgg], 42 | ); 43 | useEffect(() => { 44 | document.addEventListener("keyup", listener); 45 | return () => document.removeEventListener("keyup", listener); 46 | }, [listener]); 47 | return <>; 48 | } 49 | 50 | export function useEasterEgg() { 51 | const [easterEgg, setEasterEgg] = useState(false); 52 | const renderComponent = () => ( 53 | 54 | 55 | 56 | ); 57 | return { renderComponent, easterEgg }; 58 | } 59 | -------------------------------------------------------------------------------- /pages/[league]/xg/difference.tsx: -------------------------------------------------------------------------------- 1 | import BaseASAGridPage from "@/components/BaseASAGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { format } from "util"; 4 | import styles from "@/styles/Home.module.css"; 5 | import { transformXGMatchIntoASAMatch } from "@/utils/transform"; 6 | 7 | export default function XGForm() { 8 | return ( 9 | 10 | gridClass={styles.gridExtraWide} 11 | dataParser={(data) => { 12 | return Object.keys(data.xg).map((team, teamIdx) => { 13 | const teamData = data.xg[team]; 14 | return [ 15 | team, 16 | ...teamData.map((match, idx) => ( 17 | 20 | match.isHome 21 | ? ( 22 | Number(match.home_player_xgoals) - 23 | Number(match.away_player_xgoals) 24 | ).toFixed(3) 25 | : ( 26 | Number(match.away_player_xgoals) - 27 | Number(match.home_player_xgoals) 28 | ).toFixed(3) 29 | } 30 | match={transformXGMatchIntoASAMatch(match)} 31 | /> 32 | )), 33 | ]; 34 | }); 35 | }} 36 | pageTitle="XG Difference" 37 | endpoint={(year, league) => 38 | format(`/api/asa/xg?year=%d&league=%s`, year, league) 39 | } 40 | > 41 | Data via{" "} 42 | 43 | American Soccer Analysis API 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /utils/getLinks.ts: -------------------------------------------------------------------------------- 1 | import { format, getYear } from "date-fns"; 2 | 3 | export function getMLSLink(match: Results.Match): string { 4 | const home = match.home ? match.team : match.opponent; 5 | const away = !match.home ? match.team : match.opponent; 6 | const matchDate = new Date(match.date); 7 | const year = getYear(matchDate); 8 | const formattedDate = format(matchDate, "MM-dd-yyyy"); 9 | return `https://www.mlssoccer.com/competitions/mls-regular-season/${year}/matches/${shortNamesMap[home]}vs${shortNamesMap[away]}-${formattedDate}`; 10 | } 11 | 12 | const shortNamesMap: Record = { 13 | "Atlanta United FC": "atl", 14 | Austin: "aus", 15 | "Austin FC": "aus", 16 | Charlotte: "cha", 17 | "Charlotte FC": "cha", 18 | "Chicago Fire": "chi", 19 | "Colorado Rapids": "col", 20 | "Columbus Crew": "clb", 21 | "DC United": "dc", 22 | "FC Cincinnati": "cin", 23 | "FC Dallas": "dal", 24 | "Houston Dynamo": "hou", 25 | "Houston Dynamo FC": "hou", 26 | "Inter Miami": "mia", 27 | "Inter Miami CF": "mia", 28 | "Los Angeles FC": "lafc", 29 | "Los Angeles Galaxy": "la", 30 | "LA Galaxy": "la", 31 | "Miami United": "mia", 32 | "Minnesota United FC": "min", 33 | "Montreal Impact": "mtl", 34 | "Nashville SC": "nsh", 35 | "New England Revolution": "ne", 36 | "New York City FC": "nyc", 37 | "New York Red Bulls": "rbny", 38 | "Orlando City SC": "orl", 39 | "Philadelphia Union": "phi", 40 | "Portland Timbers": "por", 41 | "Portland Timbers FC": "por", 42 | "Real Salt Lake": "rsl", 43 | "San Jose Earthquakes": "sj", 44 | "Seattle Sounders": "sea", 45 | "Seattle Sounders FC": "sea", 46 | "Sporting Kansas City": "skc", 47 | "Toronto FC": "tor", 48 | "Vancouver Whitecaps": "van", 49 | "Vancouver Whitecaps FC": "van", 50 | }; 51 | -------------------------------------------------------------------------------- /components/BaseASAGridPage.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchGrid from "./MatchGrid"; 3 | import BaseASADataPage from "./BaseASADataPage"; 4 | import { useHomeAway } from "./Toggle/HomeAwayToggle"; 5 | import { useResultToggleAll } from "./Toggle/ResultToggle"; 6 | import { Box } from "@mui/material"; 7 | import { BasePageProps } from "./BasePage"; 8 | 9 | export default function BaseASAGridPage>({ 10 | renderControls, 11 | endpoint, 12 | dataParser, 13 | pageTitle, 14 | gridClass = styles.gridClass, 15 | children, 16 | }: { 17 | renderControls?: BasePageProps["renderControls"]; 18 | endpoint: ASA.Endpoint; 19 | dataParser: Render.GenericParserFunction; 20 | pageTitle: string; 21 | gridClass?: string; 22 | children?: React.ReactNode; 23 | }): React.ReactElement { 24 | const { value: homeAway, renderComponent: renderHomeAwayToggle } = 25 | useHomeAway(); 26 | const { value: resultToggle, renderComponent: renderResultToggle } = 27 | useResultToggleAll(); 28 | return ( 29 | 30 | endpoint={endpoint} 31 | pageTitle={pageTitle} 32 | renderControls={() => ( 33 | 34 | {renderHomeAwayToggle()} 35 | Result: {renderResultToggle()} 36 | {renderControls && renderControls()} 37 | 38 | )} 39 | renderComponent={(data) => ( 40 | 41 | homeAway={homeAway} 42 | result={resultToggle} 43 | data={data} 44 | dataParser={dataParser} 45 | gridClass={gridClass} 46 | /> 47 | )} 48 | > 49 | {children} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/BaseGridPage.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchGrid from "./MatchGrid"; 3 | import BaseDataPage, { DataPageProps } from "./BaseDataPage"; 4 | import { useHomeAway } from "./Toggle/HomeAwayToggle"; 5 | import { useResultToggleAll } from "./Toggle/ResultToggle"; 6 | import { Box } from "@mui/material"; 7 | 8 | function BaseGridPage({ 9 | renderControls, 10 | dataParser, 11 | pageTitle, 12 | gridClass = styles.gridClass, 13 | children, 14 | getEndpoint, 15 | }: { 16 | renderControls?: DataPageProps["renderControls"]; 17 | dataParser: Render.ParserFunction; 18 | pageTitle: string; 19 | gridClass?: string; 20 | children?: React.ReactNode; 21 | getEndpoint?: DataPageProps["getEndpoint"]; 22 | }): React.ReactElement { 23 | const { value: homeAway, renderComponent: renderHomeAwayToggle } = 24 | useHomeAway(); 25 | const { value: resultToggle, renderComponent: renderResultToggle } = 26 | useResultToggleAll(); 27 | return ( 28 | ( 35 | 36 | {renderHomeAwayToggle()} 37 | Result: {renderResultToggle()} 38 | {renderControls && renderControls()} 39 | 40 | )} 41 | pageTitle={pageTitle} 42 | renderComponent={(data) => ( 43 | 50 | )} 51 | > 52 | {children} 53 | 54 | ); 55 | } 56 | export default BaseGridPage; 57 | -------------------------------------------------------------------------------- /components/BaseDataPage.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import BasePage, { BasePageProps } from "./BasePage"; 3 | import { Box, CircularProgress } from "@mui/material"; 4 | import YearContext from "./Context/Year"; 5 | import LeagueContext from "./Context/League"; 6 | import { useContext } from "react"; 7 | 8 | export type DataPageProps< 9 | Data = Results.ParsedData, 10 | Meta = Results.ParsedMeta, 11 | > = { 12 | renderComponent: (data: Data, meta: Meta) => React.ReactNode; 13 | getEndpoint?: (year: number, league: string) => string; 14 | } & BasePageProps; 15 | 16 | const fetcher = (url: string) => fetch(url).then((r) => r.json()); 17 | function BaseDataPage( 18 | props: React.PropsWithChildren>, 19 | ): React.ReactElement { 20 | const { 21 | children, 22 | renderComponent, 23 | getEndpoint = (year, league) => `/api/form?year=${year}&league=${league}`, 24 | ...basePageProps 25 | } = props; 26 | const year = useContext(YearContext); 27 | const league = useContext(LeagueContext); 28 | const { data } = useSWR<{ 29 | data: Data; 30 | meta: Meta; 31 | }>(getEndpoint(year, league), fetcher, { 32 | dedupingInterval: 500, 33 | }); 34 | return ( 35 | 36 | {data && data?.data ? ( 37 | <> 38 | {renderComponent(data.data, data.meta)} 39 | {children && ( 40 | <> 41 | {children} 42 | 43 | )} 44 | 45 | ) : ( 46 | 52 | 53 | 54 | )} 55 | 56 | ); 57 | } 58 | export default BaseDataPage; 59 | -------------------------------------------------------------------------------- /components/Toggle/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ToggleButton, 3 | ToggleButtonGroup, 4 | ToggleButtonGroupProps, 5 | } from "@mui/material"; 6 | import { useState } from "react"; 7 | 8 | type Option = { 9 | value: string | number | boolean; 10 | label?: string | number; 11 | }; 12 | 13 | export function useToggle( 14 | options: Option[], 15 | defaultValue: T, 16 | { 17 | exclusive = true, 18 | allowEmpty = false, 19 | }: { exclusive?: boolean; allowEmpty?: boolean } = {}, 20 | ) { 21 | const [value, setValue] = useState(defaultValue); 22 | return { 23 | value, 24 | setValue, 25 | renderComponent: () => ( 26 | 27 | options={options} 28 | onChange={setValue} 29 | value={value} 30 | exclusive={exclusive} 31 | allowEmpty={allowEmpty} 32 | /> 33 | ), 34 | }; 35 | } 36 | 37 | export default function Toggle({ 38 | value, 39 | options = [], 40 | exclusive = true, 41 | onChange, 42 | toggleButtonGroupProps = {}, 43 | allowEmpty = false, 44 | }: { 45 | value: T | T[]; 46 | options: Option[]; 47 | exclusive: boolean; 48 | onChange: (value: T) => void; 49 | toggleButtonGroupProps?: Partial; 50 | allowEmpty: boolean; 51 | }) { 52 | return ( 53 | { 57 | if (allowEmpty || value !== null) onChange(value); 58 | }} 59 | {...toggleButtonGroupProps} 60 | > 61 | {options.map((opt, idx) => ( 62 | 68 | {opt.label ?? opt.value} 69 | 70 | ))} 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /pages/api/fixtures/[fixture].ts: -------------------------------------------------------------------------------- 1 | import getFixtureData from "@/utils/api/getFixtureData"; 2 | import { chunk } from "@/utils/array"; 3 | import { fetchCachedOrFreshV2, getHash, getKeyFromParts } from "@/utils/cache"; 4 | import { NextApiRequest, NextApiResponse } from "next"; 5 | 6 | export default async function form( 7 | req: NextApiRequest, 8 | res: NextApiResponse< 9 | | FormGuideAPI.Responses.FixturesEndpoint 10 | | FormGuideAPI.Responses.ErrorResponse 11 | >, 12 | ): Promise { 13 | const fixtures = String(req.query.fixture) 14 | .split(",") 15 | .map((f) => Number(f)); 16 | const chunks = chunk(fixtures, 10); 17 | const key = getKeyFromParts("fixtures", "chunks", 10, getHash(fixtures)); 18 | const { data: matches } = await fetchCachedOrFreshV2( 19 | key, 20 | async () => { 21 | const prepared: (FormGuideAPI.Data.Fixtures | null)[] = []; 22 | for await (const chunk of chunks) { 23 | const matches = (await Promise.all(chunk.map(getFixtureData))).filter( 24 | (m) => m !== null, 25 | ); 26 | prepared.push( 27 | ...matches 28 | .map((f) => f.data?.fixtureData?.[0] ?? null) 29 | .filter(Boolean), 30 | ); 31 | } 32 | return prepared; 33 | }, 34 | 60 * 60 * 24, 35 | ); 36 | if (!matches) { 37 | res.json({ 38 | errors: [{ message: "No matching fixtures found" }], 39 | }); 40 | return; 41 | } 42 | res.setHeader(`Cache-Control`, `s-maxage=${60 * 60}, stale-while-revalidate`); 43 | res.json({ 44 | data: matches.reduce( 45 | (acc: FormGuideAPI.Responses.FixturesEndpoint["data"], curr) => { 46 | if (curr) { 47 | return { 48 | ...acc, 49 | [curr.fixture.id]: curr, 50 | }; 51 | } else { 52 | return acc; 53 | } 54 | }, 55 | {}, 56 | ), 57 | meta: {}, 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /pages/[league]/ppg/differential.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import getTeamPoints from "@/utils/getTeamPoints"; 4 | import BasePage from "@/components/BaseGridPage"; 5 | import { getArrayAverage } from "@/utils/array"; 6 | 7 | export default function PPGOutcomes(): React.ReactElement { 8 | return ( 9 | 14 | {"Opponent PPG - Team PPG (positive — beat team with greater ppg)"} 15 | 16 | ); 17 | } 18 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 19 | const teamPoints = getTeamPoints(data); 20 | return Object.keys(data).map((team) => [ 21 | team, 22 | ...data[team] 23 | .sort((a, b) => { 24 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 25 | }) 26 | .map((match, idx) => ( 27 | { 31 | const points = getArrayAverage( 32 | teamPoints[match.opponent] 33 | .filter( 34 | (opponentMatch) => opponentMatch.date < new Date(match.date), 35 | ) 36 | .map((opponentPoints) => opponentPoints.points), 37 | ); 38 | const ownPoints = getArrayAverage( 39 | teamPoints[match.team] 40 | .filter( 41 | (opponentMatch) => opponentMatch.date < new Date(match.date), 42 | ) 43 | .map((opponentPoints) => opponentPoints.points), 44 | ); 45 | if (!match.result) { 46 | return "-"; 47 | } else { 48 | return (points - ownPoints).toFixed(2); 49 | } 50 | }} 51 | /> 52 | )), 53 | ]); 54 | } 55 | -------------------------------------------------------------------------------- /components/Grid/Base.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import Grid, { GridProps } from "./MatchGrid"; 3 | import BaseDataPage, { DataPageProps } from "../BaseDataPage"; 4 | import { useHomeAway } from "../Toggle/HomeAwayToggle"; 5 | import { useResultToggleAll } from "../Toggle/ResultToggle"; 6 | import { Box } from "@mui/material"; 7 | 8 | function BaseGridPage({ 9 | children, 10 | getEndpoint, 11 | getShaded, 12 | getValue, 13 | gridClass = styles.gridClass, 14 | pageTitle, 15 | renderControls, 16 | }: { 17 | children?: React.ReactNode; 18 | getEndpoint?: DataPageProps["getEndpoint"]; 19 | getShaded?: GridProps["getShaded"]; 20 | getValue: GridProps["getValue"]; 21 | gridClass?: string; 22 | pageTitle: string; 23 | renderControls?: DataPageProps["renderControls"]; 24 | }): React.ReactElement { 25 | const { value: homeAway, renderComponent: renderHomeAwayToggle } = 26 | useHomeAway(); 27 | const { value: resultToggle, renderComponent: renderResultToggle } = 28 | useResultToggleAll(); 29 | return ( 30 | }> 31 | {...(getEndpoint 32 | ? { 33 | getEndpoint, 34 | } 35 | : {})} 36 | pageTitle={pageTitle} 37 | renderControls={() => ( 38 | 39 | {renderHomeAwayToggle()} 40 | Result: {renderResultToggle()} 41 | {renderControls && renderControls()} 42 | 43 | )} 44 | renderComponent={(data) => ( 45 | 46 | data={data.teams} 47 | getShaded={getShaded} 48 | getValue={getValue} 49 | gridClass={gridClass} 50 | homeAway={homeAway} 51 | result={resultToggle} 52 | /> 53 | )} 54 | > 55 | {children} 56 | 57 | ); 58 | } 59 | export default BaseGridPage; 60 | -------------------------------------------------------------------------------- /utils/getRecord.tsx: -------------------------------------------------------------------------------- 1 | import { isAfter, isBefore } from "date-fns"; 2 | 3 | export type RecordPoints = [number, number, number]; 4 | export type RecordGoals = [number, number, number]; 5 | export function getRecord( 6 | matches: Results.Match[], 7 | { 8 | home = null, 9 | away = null, 10 | from = null, 11 | to = null, 12 | }: Partial<{ 13 | home: boolean | null; 14 | away: boolean | null; 15 | from: Date | null; 16 | to: Date | null; 17 | }> = {}, 18 | ): RecordPoints { 19 | return (matches ?? []) 20 | .filter( 21 | (match) => 22 | match.status.long === "Match Finished" && 23 | ((home !== null ? match.home === home : true) || 24 | (away !== null ? !match.home === away : true)), 25 | ) 26 | .filter((match) => (from ? isAfter(new Date(match.rawDate), from) : true)) 27 | .filter((match) => (to ? isBefore(new Date(match.rawDate), to) : true)) 28 | .reduce( 29 | (prev, curr) => { 30 | return curr 31 | ? [ 32 | prev[0] + (curr.result === "W" ? 1 : 0), 33 | prev[1] + (curr.result === "D" ? 1 : 0), 34 | prev[2] + (curr.result === "L" ? 1 : 0), 35 | ] 36 | : prev; 37 | }, 38 | [0, 0, 0], 39 | ); 40 | } 41 | 42 | /** 43 | * 44 | * @return [goalsFor, goalsAgainst, goalDifference] 45 | */ 46 | export function getGoals(matches: Results.Match[]): RecordGoals { 47 | return (matches ?? []) 48 | .filter((match) => match.status.long === "Match Finished") 49 | .reduce( 50 | (prev, curr) => { 51 | return curr 52 | ? [ 53 | prev[0] + (curr.goalsScored ?? 0), 54 | prev[1] + (curr.goalsConceded ?? 0), 55 | prev[2] + ((curr.goalsScored ?? 0) - (curr.goalsConceded ?? 0)), 56 | ] 57 | : prev; 58 | }, 59 | [0, 0, 0], 60 | ); 61 | } 62 | 63 | export function getRecordPoints(record: RecordPoints): number { 64 | return record[0] * 3 + record[1]; 65 | } 66 | -------------------------------------------------------------------------------- /functions/src/prediction.ts: -------------------------------------------------------------------------------- 1 | import { http } from "@google-cloud/functions-framework"; 2 | import { config } from "dotenv"; 3 | import { getFixture, getPredictionsForFixture } from "./football-api"; 4 | import { fetchCachedOrFresh } from "./utils"; 5 | 6 | config(); 7 | 8 | http("prediction", async (req, res) => { 9 | res.header("Content-Type", "application/json"); 10 | const fixture = Number(req.query.fixture?.toString()); 11 | if (!fixture || Number.isNaN(fixture)) { 12 | res.json({ 13 | meta: { fixture }, 14 | errors: [ 15 | { 16 | message: "query param `fixture` must be a number", 17 | }, 18 | ], 19 | data: null, 20 | }); 21 | return; 22 | } 23 | try { 24 | const [fixtureData, fromCache] = await fetchCachedOrFresh< 25 | Results.FixtureApi[] 26 | >( 27 | `prediction-api:v2:fixture:${fixture}`, 28 | async () => getFixture(fixture), 29 | (data) => 30 | !data 31 | ? 30 32 | : data?.[0].fixture.status.long === "Match Finished" 33 | ? 0 34 | : data?.[0].fixture.status.short === "NS" 35 | ? 60 * 60 * 4 // 4 hours if the match has not started 36 | : 60 * 15, // 15 minutes if the match has started 37 | ); 38 | console.info("Fetching data", fixture, Boolean(fixtureData), fromCache); 39 | const [predictionData] = await fetchCachedOrFresh( 40 | `prediction-api:v2:predictions:${fixture}`, 41 | async () => getPredictionsForFixture(fixture), 42 | (data) => 43 | fixtureData?.[0].fixture.status.long === "Match Finished" 44 | ? 0 // store in perpetuity if match is finished 45 | : Boolean(data) 46 | ? 60 * 60 * 24 47 | : 60 * 60, // one minute if failed, 24 hours if not 48 | ); 49 | res.json({ 50 | errors: [], 51 | data: { fixtureData, predictionData }, 52 | meta: { fixture }, 53 | }); 54 | } catch (e) { 55 | res.json({ 56 | errors: [e], 57 | }); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /pages/[league]/since-result/opponent/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { useResultToggle } from "@/components/Toggle/ResultToggle"; 4 | import { getInverseResult } from "@/utils/results"; 5 | import { isBefore, parseISO } from "date-fns"; 6 | 7 | const formattedResults: Record = { 8 | D: "draw", 9 | L: "loss", 10 | W: "win", 11 | }; 12 | 13 | export default function OpponentSinceResultPage(): React.ReactElement { 14 | const { value: result, renderComponent } = useResultToggle(); 15 | return ( 16 | <>Result: {renderComponent()}} 18 | pageTitle={`Opponent Games since a ${formattedResults[result]} ${ 19 | result === "W" 20 | ? "(Slumpbusters)" 21 | : result === "L" 22 | ? "(Streakbusters)" 23 | : "" 24 | }`} 25 | dataParser={(teams) => dataParser(teams, result)} 26 | /> 27 | ); 28 | } 29 | 30 | function dataParser( 31 | data: Results.ParsedData["teams"], 32 | resultType: Results.ResultTypes = "W", 33 | ): Render.RenderReadyData { 34 | return Object.keys(data).map((team) => [ 35 | team, 36 | ...data[team] 37 | .sort((a, b) => (new Date(a.date) > new Date(b.date) ? 1 : -1)) 38 | .map((match, idx) => { 39 | const lastResult = data[match.opponent] 40 | .filter((m) => isBefore(parseISO(m.rawDate), parseISO(match.rawDate))) 41 | .filter((m) => m.result) 42 | .reverse() 43 | .findIndex((m) => m.result && resultType === m.result); 44 | return ( 45 | (lastResult === -1 ? "-" : lastResult)} 49 | isShaded={() => { 50 | return ( 51 | match.result !== getInverseResult(resultType) || lastResult <= 1 52 | ); 53 | }} 54 | /> 55 | ); 56 | }), 57 | ]); 58 | } 59 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | margin: 1rem; 3 | } 4 | .grid, 5 | .gridWide, 6 | .gridExtraWide, 7 | .chart { 8 | display: grid; 9 | row-gap: 0.125rem; 10 | } 11 | .gridRow { 12 | display: grid; 13 | grid-template-columns: 25px 160px repeat(46, 32px); 14 | } 15 | .gridRow > :first-child:empty { 16 | width: 1px; 17 | } 18 | .gridWide .gridRow { 19 | grid-template-columns: 25px 160px repeat(46, 40px); 20 | } 21 | .gridExtraWide .gridRow { 22 | grid-template-columns: 25px 160px repeat(46, 50px); 23 | } 24 | .gridXXWide .gridRow { 25 | grid-template-columns: 25px 160px repeat(46, 60px); 26 | } 27 | .gridRow > a[data-home="home"] { 28 | text-decoration: underline; 29 | } 30 | .gridRowHeaderCell { 31 | cursor: pointer; 32 | font-size: 9pt; 33 | font-weight: bold; 34 | text-align: center; 35 | } 36 | 37 | .matchDetails { 38 | position: absolute; 39 | opacity: 1; 40 | filter: none; 41 | margin: 0; 42 | text-align: left; 43 | left: 25px; 44 | z-index: 100; 45 | font-style: normal; 46 | } 47 | .matchDetailsOpponent { 48 | display: flex; 49 | justify-content: flex-start; 50 | } 51 | .matchDetailsLogo { 52 | margin-right: 0.25rem; 53 | height: 50px; 54 | width: 50px; 55 | position: relative; 56 | } 57 | 58 | .chartRow { 59 | display: grid; 60 | grid-template-columns: 25px 200px repeat(auto-fit, 28px); 61 | height: 30px; 62 | } 63 | /* .chartWide .chartRow { 64 | grid-template-columns: 25px 200px repeat(auto-fit, 40px); 65 | } */ 66 | .chartWide .chartRow { 67 | grid-template-columns: 25px 200px repeat(auto-fit, 80px); 68 | } 69 | .chartTeamSmall { 70 | font-size: 9pt; 71 | font-weight: bold; 72 | align-items: baseline; 73 | text-align: right; 74 | padding-right: 0.5rem; 75 | align-self: center; 76 | } 77 | .chartRow > :not(.chartTeam) { 78 | position: relative; 79 | } 80 | .chartPointText { 81 | position: relative; 82 | display: block; 83 | text-align: center; 84 | z-index: 10; 85 | font-size: 12px; 86 | } 87 | 88 | .gridFilledGrey { 89 | background-color: #e0e0e0; 90 | text-align: center; 91 | } 92 | -------------------------------------------------------------------------------- /utils/getPpg.ts: -------------------------------------------------------------------------------- 1 | import { getArraySum } from "./array"; 2 | import { getTeamPointsArray } from "./getTeamPoints"; 3 | 4 | export default function getPPG( 5 | data: Results.ParsedData["teams"], 6 | ): Record { 7 | return Object.entries(data) 8 | .map(([team, matches]): [string, { home: number; away: number }] => { 9 | const homePoints = getTeamPointsArray(matches.filter((m) => m.home)); 10 | const awayPoints = getTeamPointsArray(matches.filter((m) => !m.home)); 11 | return [ 12 | team, 13 | { 14 | home: getArraySum(homePoints) / homePoints.length, 15 | away: getArraySum(awayPoints) / awayPoints.length, 16 | }, 17 | ]; 18 | }) 19 | .reduce((acc, [team, homeAway]) => { 20 | return { ...acc, [team]: homeAway }; 21 | }, {}); 22 | } 23 | export type Probabilities = { 24 | homeW: number; 25 | homeD: number; 26 | homeL: number; 27 | awayW: number; 28 | awayD: number; 29 | awayL: number; 30 | }; 31 | export function getProbabilities( 32 | data: Results.ParsedData["teams"], 33 | ): Record { 34 | return Object.entries(data) 35 | .map(([team, matches]): [string, Probabilities] => { 36 | const homeGames = getTeamPointsArray(matches.filter((m) => m.home)); 37 | const awayGames = getTeamPointsArray(matches.filter((m) => !m.home)); 38 | return [ 39 | team, 40 | { 41 | homeW: homeGames.filter((p) => p === 3).length / homeGames.length, 42 | homeD: homeGames.filter((p) => p === 1).length / homeGames.length, 43 | homeL: homeGames.filter((p) => p === 0).length / homeGames.length, 44 | awayW: awayGames.filter((p) => p === 3).length / awayGames.length, 45 | awayD: awayGames.filter((p) => p === 1).length / awayGames.length, 46 | awayL: awayGames.filter((p) => p === 0).length / awayGames.length, 47 | }, 48 | ]; 49 | }) 50 | .reduce((acc, [team, homeAway]) => { 51 | return { ...acc, [team]: homeAway }; 52 | }, {}); 53 | } 54 | -------------------------------------------------------------------------------- /pages/[league]/points/off-top.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import BasePage from "@/components/BaseGridPage"; 3 | import getMatchPoints from "@/utils/getMatchPoints"; 4 | 5 | export default function GoalDifference(): React.ReactElement { 6 | return ; 7 | } 8 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 9 | const cumulative: Record = {}; 10 | Object.keys(data).map((team) => [ 11 | team, 12 | ...data[team] 13 | .sort((a, b) => { 14 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 15 | }) 16 | .map((match, idx) => { 17 | cumulative[team] = cumulative[team] || []; 18 | cumulative[team][idx] = 19 | (cumulative?.[team]?.[idx - 1] || 0) + getMatchPoints(match); 20 | }), 21 | ]); 22 | const pointsOffTop: Record = {}; 23 | Object.keys(data).map((team) => [ 24 | team, 25 | ...data[team] 26 | .sort((a, b) => { 27 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 28 | }) 29 | .map((_, idx) => { 30 | const topForWeek = Object.keys(cumulative) 31 | .sort((a, b) => { 32 | return cumulative[a][idx] > cumulative[b][idx] 33 | ? 1 34 | : cumulative[a][idx] === cumulative[b][idx] 35 | ? 0 36 | : -1; 37 | }) 38 | .reverse()[0]; 39 | pointsOffTop[team] = pointsOffTop[team] || []; 40 | pointsOffTop[team][idx] = 41 | cumulative[topForWeek][idx] - cumulative[team][idx]; 42 | }), 43 | ]); 44 | return Object.keys(data).map((team) => [ 45 | team, 46 | ...data[team] 47 | .sort((a, b) => { 48 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 49 | }) 50 | .map((match, idx) => { 51 | return ( 52 | pointsOffTop[team][idx]} 56 | /> 57 | ); 58 | }), 59 | ]); 60 | } 61 | -------------------------------------------------------------------------------- /pages/[league]/stats/rolling/finishing.tsx: -------------------------------------------------------------------------------- 1 | import BaseRollingPage from "@/components/Rolling/Base"; 2 | import { getArraySum } from "@/utils/array"; 3 | import { getStats, ValidStats } from "@/components/Stats"; 4 | import { Box } from "@mui/material"; 5 | import { useOpponentToggle } from "@/components/Toggle/OpponentToggle"; 6 | import { useToggle } from "@/components/Toggle/Toggle"; 7 | 8 | export default function Chart(): React.ReactElement { 9 | const { value: showOpponent, renderComponent: renderOpponentToggle } = 10 | useOpponentToggle(); 11 | const { value: stat, renderComponent: renderStatsToggle } = 12 | useToggle( 13 | [ 14 | { value: "shots", label: "Shots" }, 15 | { value: "shots-on-goal", label: "SOT" }, 16 | ], 17 | "shots", 18 | ); 19 | const max = stat === "shots" ? 0.4 : 0.8; 20 | return ( 21 | 22 | renderControls={() => ( 23 | <> 24 | {renderOpponentToggle()} 25 | {renderStatsToggle()} 26 | 27 | )} 28 | isWide 29 | getEndpoint={(year, league) => `/api/stats/${league}?year=${year}`} 30 | numberFormat={(n) => { 31 | if (n === null) { 32 | return ""; 33 | } 34 | return Number(n).toPrecision(2); 35 | }} 36 | pageTitle={`Rolling finishing (%s game rolling)`} 37 | getValue={(match) => { 38 | const resultShots = 39 | getStats(match, stat)[showOpponent === "opponent" ? 1 : 0] ?? 0; 40 | const resultGoals = 41 | showOpponent === "opponent" 42 | ? match.goalsConceded ?? 0 43 | : match.goalsScored ?? 0; 44 | return [Number(resultGoals), Number(resultShots)]; 45 | }} 46 | getSummaryValue={(value) => { 47 | return ( 48 | getArraySum(value.map((v) => v[0])) / 49 | getArraySum(value.map((v) => v[1])) 50 | ); 51 | }} 52 | getBoxHeight={(value) => { 53 | return `${value ? (value / max) * 100 : 0}%`; 54 | }} 55 | /> 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /components/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, PropsWithChildren, SetStateAction, useState } from "react"; 2 | import styles from "@/styles/Home.module.css"; 3 | import { Box, ClickAwayListener, SxProps } from "@mui/material"; 4 | export type MatchCellProps = { 5 | getBackgroundColor: () => string; 6 | isShaded?: (...args: unknown[]) => boolean; 7 | onClick?: () => void; 8 | renderCard?: (setOpen: Dispatch>) => React.ReactNode; 9 | rightBorder?: boolean; 10 | sx?: SxProps; 11 | } & PropsWithChildren; 12 | 13 | export default function Cell({ 14 | children, 15 | getBackgroundColor, 16 | isShaded, 17 | onClick, 18 | renderCard, 19 | rightBorder = false, 20 | sx = {}, 21 | }: MatchCellProps): React.ReactElement { 22 | const [open, setOpen] = useState(false); 23 | 24 | return ( 25 | 40 | setOpen(false)}> 41 | 42 | {open && renderCard ? renderCard(setOpen) : null} 43 | { 56 | if (typeof onClick === "function") { 57 | onClick(); 58 | } 59 | setOpen(true); 60 | }} 61 | > 62 | {children} 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /pages/[league]/plus-minus/index.tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import Table from "@/components/Table"; 3 | import { GridToolbar } from "@mui/x-data-grid"; 4 | 5 | export default function PlusMinusPage() { 6 | return ( 7 | 8 | getEndpoint={(year, league) => `/api/plus-minus/${league}?year=${year}`} 9 | pageTitle="Plus-Minus" 10 | renderComponent={(data) => } 11 | /> 12 | ); 13 | } 14 | 15 | function PlusMinus({ data }: { data: Results.MatchWithGoalData }) { 16 | const rows = Object.entries(data).reduce( 17 | (acc: Row[], [team, players]): Row[] => { 18 | return [ 19 | ...acc, 20 | ...(players 21 | ? Object.entries(players).map( 22 | ([player, minutes]: [ 23 | string, 24 | FormGuideAPI.Data.PlusMinus, 25 | ]): Row => { 26 | return { 27 | id: player, 28 | player, 29 | team, 30 | plusMinus: minutes.onGF - minutes.onGA, 31 | minutes: minutes.minutes, 32 | matches: minutes.matches, 33 | }; 34 | }, 35 | ) 36 | : []), 37 | ]; 38 | }, 39 | [], 40 | ); 41 | return ( 42 | 43 | gridProps={{ 44 | slots: { toolbar: GridToolbar }, 45 | }} 46 | columns={() => [ 47 | { field: "team", width: 200 }, 48 | { field: "player", width: 200 }, 49 | { 50 | field: "plusMinus", 51 | header: "+/-", 52 | valueFormatter: (a: { value: number }) => 53 | a.value >= 0 ? `+${a.value}` : a.value, 54 | }, 55 | { 56 | field: "minutes", 57 | valueFormatter: (a: { value: number }) => 58 | Number(a.value).toLocaleString(), 59 | }, 60 | { field: "matches" }, 61 | ]} 62 | data={rows} 63 | /> 64 | ); 65 | } 66 | 67 | type Row = { 68 | id: string; 69 | player: string; 70 | team: string; 71 | plusMinus: number; 72 | minutes: number; 73 | matches: number; 74 | }; 75 | -------------------------------------------------------------------------------- /pages/[league]/ppg/opponent.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import getTeamPoints from "@/utils/getTeamPoints"; 4 | import BaseGridPage from "@/components/BaseGridPage"; 5 | import { getArrayAverageFormatted } from "@/utils/array"; 6 | import { useState } from "react"; 7 | import { FormControlLabel, Switch } from "@mui/material"; 8 | 9 | export default function PPGOpponent(): React.ReactElement { 10 | const [useHomeAway, setUseHomeAway] = useState(true); 11 | return ( 12 | dataParser(data, useHomeAway)} 14 | pageTitle="Opponent PPG before given match" 15 | gridClass={styles.gridWide} 16 | > 17 | setUseHomeAway(ev.currentTarget.checked)} /> 22 | } 23 | > 24 | 25 | ); 26 | } 27 | function dataParser( 28 | data: Results.ParsedData["teams"], 29 | useHomeAway = true, 30 | ): Render.RenderReadyData { 31 | const teamPoints = getTeamPoints(data); 32 | return Object.keys(data).map((team) => [ 33 | team, 34 | ...data[team] 35 | .sort((a, b) => { 36 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 37 | }) 38 | .map((match, idx) => ( 39 | { 43 | const points = teamPoints[match.opponent] 44 | .filter( 45 | (opponentMatch) => 46 | opponentMatch.date < new Date(match.date) && 47 | opponentMatch.result !== null, 48 | ) 49 | .filter((opponentMatch) => { 50 | if (useHomeAway) { 51 | return opponentMatch.home === !match.home; 52 | } 53 | return true; 54 | }) 55 | .map((opponentPoints) => opponentPoints.points); 56 | return getArrayAverageFormatted(points); 57 | }} 58 | /> 59 | )), 60 | ]); 61 | } 62 | -------------------------------------------------------------------------------- /components/Results.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SvgIconComponent } from "@mui/icons-material"; 3 | import { Box } from "@mui/material"; 4 | import { ActionImpl, KBarResults, useMatches } from "@refinedev/kbar"; 5 | 6 | export default function Results({ darkMode }: { darkMode: boolean }) { 7 | const { results } = useMatches(); 8 | return ( 9 | 13 | typeof item === "string" ? ( 14 | 19 | ) : ( 20 | 21 | ) 22 | } 23 | /> 24 | ); 25 | } 26 | 27 | type ResultItemProps = { 28 | item: Partial; 29 | active: boolean; 30 | darkMode: boolean; 31 | }; 32 | 33 | export function ResultItem({ 34 | item, 35 | active, 36 | darkMode, 37 | }: ResultItemProps): React.ReactElement { 38 | const Icon = 39 | typeof item.icon === "object" 40 | ? (item.icon as unknown as SvgIconComponent) 41 | : React.Fragment; 42 | return ( 43 | 63 | 64 | {item.icon && } 65 | 66 | {typeof item === "string" ? item : item.name} 67 | 68 | ); 69 | } 70 | 71 | // eslint-disable-next-line react/display-name 72 | const ResultItemWithRef = React.forwardRef((props: ResultItemProps) => ( 73 | 74 | )); 75 | -------------------------------------------------------------------------------- /utils/api/getFixtureData.ts: -------------------------------------------------------------------------------- 1 | import { isBefore, parseISO } from "date-fns"; 2 | import { fetchCachedOrFreshV2, getKeyFromParts } from "../cache"; 3 | import { SlimMatch } from "../getAllFixtureIds"; 4 | 5 | const ENDPOINT = process.env.PREDICTIONS_API; 6 | 7 | export const FIXTURE_KEY_PREFIX = `fixture-data:v1.0.10:`; 8 | 9 | export default async function getFixtureData(fixture: number) { 10 | return fetchCachedOrFreshV2( 11 | getKeyFromParts(FIXTURE_KEY_PREFIX, fixture), 12 | async () => { 13 | const response = await fetch(`${ENDPOINT}?fixture=${fixture}`); 14 | if (response.status !== 200) { 15 | throw `function response: ${response.statusText}`; 16 | } 17 | const responseJson = await response.json(); 18 | if (responseJson.errors.length) { 19 | throw `function errors: ${JSON.stringify(responseJson.errors)}`; 20 | } 21 | return responseJson.data; 22 | }, 23 | (data) => 24 | data.fixtureData?.[0].fixture.status.long === "Match Finished" 25 | ? 0 // no expiration for completed matches 26 | : isBefore(parseISO(data.fixtureData[0]?.fixture.date), new Date()) 27 | ? 60 * 60 // 1 hour for matches from today forward 28 | : 60 * 60 * 24, // 24 hour cache for incomplete matches 29 | { 30 | checkEmpty: (data) => { 31 | if (!data) return true; 32 | try { 33 | const d = JSON.parse(data) as FormGuideAPI.Data.Fixture; 34 | if ( 35 | !d || 36 | !d.fixtureData || 37 | (typeof d.fixtureData === "object" && 38 | Object.keys(d.fixtureData).length === 0) 39 | ) { 40 | return true; 41 | } 42 | return false; 43 | } catch (e) { 44 | return true; 45 | } 46 | }, 47 | retryOnEmptyData: true, 48 | allowCompression: true, 49 | }, 50 | ); 51 | } 52 | 53 | export async function fetchFixture( 54 | fixture: SlimMatch, 55 | ): Promise { 56 | try { 57 | const { data } = await getFixtureData(fixture.fixtureId); 58 | 59 | return data?.fixtureData[0] ?? null; 60 | } catch (e) { 61 | console.error(e); 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pages/[league]/since-result/[result].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { isBefore, parseISO } from "date-fns"; 4 | import { useRouter } from "next/router"; 5 | 6 | const formattedResults: Record = { 7 | D: "draw", 8 | L: "loss", 9 | W: "win", 10 | }; 11 | 12 | export default function SinceResultPage(): React.ReactElement { 13 | const router = useRouter(); 14 | const result: Results.ResultTypes[] = (router.query.result 15 | ?.toString() 16 | ?.split(",") as Results.ResultTypes[]) || ["W"]; 17 | return ( 18 | formattedResults[r]) 21 | .join(" or ")}`} 22 | dataParser={(teams) => dataParser(teams, result)} 23 | /> 24 | ); 25 | } 26 | 27 | function dataParser( 28 | data: Results.ParsedData["teams"], 29 | resultTypes: Results.ResultTypes[], 30 | ): Render.RenderReadyData { 31 | const lastTeamResult: Record = {}; 32 | return Object.keys(data).map((team) => [ 33 | team, 34 | ...data[team] 35 | .sort((a, b) => (new Date(a.date) > new Date(b.date) ? 1 : -1)) 36 | .map((match, idx) => { 37 | if (typeof lastTeamResult[team] === "undefined") { 38 | lastTeamResult[team] = 0; 39 | } 40 | if ( 41 | resultTypes.filter( 42 | (result) => match.result?.toLowerCase() === result.toLowerCase(), 43 | ).length > 0 44 | ) { 45 | lastTeamResult[team] = idx; 46 | } 47 | const lastResult = [...data[team]] 48 | .reverse() 49 | .find( 50 | (m) => 51 | resultTypes.includes(m.result as Results.ResultTypes) && 52 | isBefore(parseISO(m.rawDate), parseISO(match.rawDate)), 53 | ); 54 | const lastResultIdx = data[team].findIndex( 55 | (m) => m.fixtureId === lastResult?.fixtureId, 56 | ); 57 | return ( 58 | 62 | typeof lastTeamResult[team] !== "undefined" && 63 | (match.result || data[team][idx - 1]?.result) 64 | ? idx - lastResultIdx - 1 65 | : "-" 66 | } 67 | /> 68 | ); 69 | }), 70 | ]); 71 | } 72 | -------------------------------------------------------------------------------- /components/Rolling/AbstractBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, ClickAwayListener } from "@mui/material"; 2 | import styles from "@/styles/Home.module.css"; 3 | import { useState } from "react"; 4 | 5 | export type NumberFormat = (value: number | null) => string; 6 | 7 | export type AbstractRollingBoxProps = { 8 | backgroundColor: string; 9 | boxHeight: string; 10 | numberFormat?: NumberFormat; 11 | renderCardContent?: () => React.ReactNode; 12 | value: number | null; 13 | }; 14 | 15 | export default function AbstractRollingBox({ 16 | backgroundColor, 17 | boxHeight, 18 | numberFormat = (value: number | null): string => 19 | typeof value === "number" 20 | ? Number.isInteger(value) 21 | ? value.toString() 22 | : value?.toFixed(1) 23 | : "", 24 | renderCardContent, 25 | value, 26 | }: AbstractRollingBoxProps): React.ReactElement { 27 | const [showCard, setShowCard] = useState(false); 28 | return ( 29 | setShowCard(true)} 41 | > 42 | {renderCardContent && showCard && ( 43 | setShowCard(false)}> 44 | 55 | 56 | {(renderCardContent && renderCardContent()) ?? <>} 57 | 58 | 59 | 60 | )} 61 | 65 | {numberFormat(value)} 66 | 67 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /pages/[league]/ppg/outcomes.tsx: -------------------------------------------------------------------------------- 1 | import MatchCell from "@/components/MatchCell"; 2 | import getTeamPoints from "@/utils/getTeamPoints"; 3 | import BasePage from "@/components/BaseGridPage"; 4 | import { Typography } from "@mui/material"; 5 | 6 | export default function PPGOutcomes(): React.ReactElement { 7 | return ( 8 | 9 | Legend 10 |
    11 |
  • ++: Beat team with greater PPG
  • 12 |
  • +: Beat team with lesser PPG
  • 13 |
  • {"//: Drew team with greater PPG"}
  • 14 |
  • {"/: Drew team with lesser PPG"}
  • 15 |
  • -: Lost to team with greater PPG
  • 16 |
  • --: Lost to team with lesser PPG
  • 17 |
18 |
19 | ); 20 | } 21 | function dataParser(data: Results.ParsedData["teams"]): Render.RenderReadyData { 22 | const teamPoints = getTeamPoints(data); 23 | return Object.keys(data).map((team) => [ 24 | team, 25 | ...data[team] 26 | .sort((a, b) => { 27 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 28 | }) 29 | .map((match, idx) => ( 30 | { 34 | const points = getArraySum( 35 | teamPoints[match.opponent] 36 | .filter( 37 | (opponentMatch) => opponentMatch.date < new Date(match.date), 38 | ) 39 | .map((opponentPoints) => opponentPoints.points), 40 | ); 41 | const ownPoints = getArraySum( 42 | teamPoints[match.team] 43 | .filter( 44 | (opponentMatch) => opponentMatch.date < new Date(match.date), 45 | ) 46 | .map((opponentPoints) => opponentPoints.points), 47 | ); 48 | if (!match.result) { 49 | return "-"; 50 | } else if (match.result === "W") { 51 | return points > ownPoints ? "++" : "+"; 52 | } else if (match.result === "L") { 53 | return points > ownPoints ? "-" : "--"; 54 | } else { 55 | return points > ownPoints ? "//" : "/"; 56 | } 57 | }} 58 | /> 59 | )), 60 | ]); 61 | } 62 | 63 | function getArraySum(values: number[]): number { 64 | return values.length ? values.reduce((sum, curr) => sum + curr, 0) : 0; 65 | } 66 | -------------------------------------------------------------------------------- /components/BasePage.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import { Grid, Typography } from "@mui/material"; 3 | import { useContext } from "react"; 4 | import YearContext from "./Context/Year"; 5 | import LeagueContext from "./Context/League"; 6 | import { Box, Paper } from "@mui/material"; 7 | import { NextSeo } from "next-seo"; 8 | import { LeagueOptions } from "@/utils/Leagues"; 9 | 10 | export type BasePageProps = { 11 | pageTitle: React.ReactNode | string; 12 | renderTitle?: () => React.ReactNode; 13 | renderControls?: () => React.ReactNode; 14 | } & React.PropsWithChildren; 15 | 16 | export default function BasePage({ 17 | children, 18 | pageTitle, 19 | renderControls, 20 | renderTitle, 21 | }: BasePageProps): React.ReactElement { 22 | const year = useContext(YearContext); 23 | const league = useContext(LeagueContext); 24 | return ( 25 | <> 26 | 34 |
35 | 36 | 37 | Year: {year}, League: {LeagueOptions[league]} 38 | 39 | {typeof renderTitle === "function" ? ( 40 | {renderTitle()} 41 | ) : pageTitle ? ( 42 | {pageTitle} 43 | ) : ( 44 | <> 45 | )} 46 | 47 |
55 | {renderControls && ( 56 | 65 | 66 | {renderControls()} 67 | 68 | 69 | )} 70 | 71 | {children} 72 | 73 |
74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /utils/results.ts: -------------------------------------------------------------------------------- 1 | import { colors } from "@mui/material"; 2 | 3 | export function getInverseResult( 4 | result: Results.ResultType, 5 | ): Results.ResultType { 6 | return result === null 7 | ? null 8 | : result === "W" 9 | ? "L" 10 | : result === "L" 11 | ? "W" 12 | : "D"; 13 | } 14 | export function stepResult(result: Results.ResultType): Results.ResultType { 15 | if (result === "W") { 16 | return "D"; 17 | } else if (result === "D") { 18 | return "L"; 19 | } else if (result === "L") { 20 | return null; 21 | } else { 22 | return "W"; 23 | } 24 | } 25 | 26 | export function getResultBackgroundColor(result?: Results.ResultType): string { 27 | return !result 28 | ? "background.default" 29 | : result === "W" 30 | ? "success.main" 31 | : result === "L" 32 | ? "error.main" 33 | : "warning.main"; 34 | } 35 | export function getResultGradient( 36 | value: number, 37 | scale: number[], 38 | colors: string[], 39 | distanceCheck: (value: number, scaleValue: number) => boolean = ( 40 | value, 41 | scaleValue, 42 | ) => value - scaleValue <= 5, 43 | ): string { 44 | // find the closest number in the scale 45 | const scaleValue = scale.find((scaleValue) => { 46 | return distanceCheck(value, scaleValue); 47 | }); 48 | return colors[scale.findIndex((s) => s == scaleValue)]; 49 | } 50 | 51 | export function getMinutesColor(value: number): string { 52 | if (value === 0) { 53 | return colors.indigo["100"]; 54 | } 55 | return getResultGradient( 56 | value, 57 | [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100], 58 | [ 59 | colors.red["300"], 60 | colors.orange["400"], 61 | colors.orange["200"], 62 | colors.amber["700"], 63 | colors.amber["500"], 64 | colors.amber["300"], 65 | colors.green["200"], 66 | colors.green["300"], 67 | colors.green["400"], 68 | colors.green["500"], 69 | colors.green["500"], 70 | ], 71 | ); 72 | } 73 | 74 | export function getSmallStatsColor(value: number): string { 75 | if (value === 0) { 76 | return colors.indigo["100"]; 77 | } 78 | return getResultGradient( 79 | value, 80 | [0, 1, 2, 3, 4], 81 | [ 82 | colors.indigo["100"], 83 | colors.green["400"], 84 | colors.amber["400"], 85 | colors.orange["400"], 86 | colors.purple["400"], 87 | colors.deepPurple["400"], 88 | ], 89 | (value, scaleValue) => value === scaleValue, 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /components/Grid/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from "react"; 2 | import styles from "@/styles/Home.module.css"; 3 | import { Box, ClickAwayListener, SxProps } from "@mui/material"; 4 | import { getResultBackgroundColor } from "@/utils/results"; 5 | export type CellProps = { 6 | getBackgroundColor: () => string; 7 | isShaded?: (...args: unknown[]) => boolean; 8 | onClick?: () => void; 9 | renderCard?: (setOpen: Dispatch>) => React.ReactNode; 10 | renderValue: () => React.ReactNode; 11 | rightBorder?: boolean; 12 | sx?: SxProps; 13 | }; 14 | 15 | export default function Cell({ 16 | getBackgroundColor, 17 | isShaded, 18 | onClick, 19 | renderCard, 20 | renderValue, 21 | rightBorder = false, 22 | sx = {}, 23 | }: CellProps): React.ReactElement { 24 | const [open, setOpen] = useState(false); 25 | 26 | return ( 27 | 42 | setOpen(false)}> 43 | 44 | {open && renderCard ? renderCard(setOpen) : null} 45 | { 58 | if (typeof onClick === "function") { 59 | onClick(); 60 | } 61 | setOpen(true); 62 | }} 63 | > 64 | {renderValue()} 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | export function getDefaultBackgroundColor(match: Results.Match) { 73 | switch (match.result) { 74 | case "W": 75 | case "D": 76 | case "L": 77 | return getResultBackgroundColor(match.result); 78 | default: 79 | return "background.default"; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/codacy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow checks out code, performs a Codacy security scan 7 | # and integrates the results with the 8 | # GitHub Advanced Security code scanning feature. For more information on 9 | # the Codacy security scan action usage and parameters, see 10 | # https://github.com/codacy/codacy-analysis-cli-action. 11 | # For more information on Codacy Analysis CLI in general, see 12 | # https://github.com/codacy/codacy-analysis-cli. 13 | 14 | name: Codacy Security Scan 15 | 16 | on: 17 | push: 18 | branches: [main] 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: [main] 22 | schedule: 23 | - cron: "27 16 * * 6" 24 | 25 | permissions: 26 | contents: read 27 | 28 | jobs: 29 | codacy-security-scan: 30 | permissions: 31 | contents: read # for actions/checkout to fetch code 32 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 33 | name: Codacy Security Scan 34 | runs-on: ubuntu-latest 35 | steps: 36 | # Checkout the repository to the GitHub Actions runner 37 | - name: Checkout code 38 | uses: actions/checkout@v2 39 | 40 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 41 | - name: Run Codacy Analysis CLI 42 | uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b 43 | with: 44 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 45 | # You can also omit the token and run the tools that support default configurations 46 | # project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 47 | verbose: true 48 | output: results.sarif 49 | format: sarif 50 | # Adjust severity of non-security issues 51 | gh-code-scanning-compat: true 52 | # Force 0 exit code to allow SARIF file generation 53 | # This will handover control about PR rejection to the GitHub side 54 | max-allowed-issues: 2147483647 55 | 56 | # Upload the SARIF file generated in the previous step 57 | - name: Upload SARIF results file 58 | uses: github/codeql-action/upload-sarif@v1 59 | with: 60 | sarif_file: results.sarif 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formguide", 3 | "version": "1.43.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "dev:test": "jest --watch", 8 | "lint": "run-p lint:*", 9 | "lint:next": "next lint", 10 | "lint:build": "tsc --noEmit", 11 | "lint:prettier": "prettier --check .", 12 | "prettier": "prettier --write .", 13 | "_lint:css": "csslint **/*.css", 14 | "build": "next build", 15 | "changelog": "npx conventional-changelog-cli -i CHANGELOG.md --same-file -p conventionalcommits", 16 | "start": "next start", 17 | "setup": "run-p setup:*", 18 | "setup:functions": "npm install --prefix ./functions", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@emotion/react": "^11.14.0", 23 | "@emotion/styled": "^11.14.1", 24 | "@mui/icons-material": "^7.3.2", 25 | "@mui/material": "^7.3.2", 26 | "@mui/x-data-grid": "^8.12.1", 27 | "@refinedev/kbar": "^2.0.0", 28 | "@visx/curve": "3.12.0", 29 | "@visx/legend": "3.12.0", 30 | "@visx/responsive": "3.12.0", 31 | "@visx/scale": "3.12.0", 32 | "@visx/tooltip": "3.12.0", 33 | "@visx/xychart": "3.12.0", 34 | "calculate-correlation": "^1.2.3", 35 | "csv": "^6.4.1", 36 | "d3-color-1-fix": "*", 37 | "date-fns": "^4.1.0", 38 | "husky": "^9.1.7", 39 | "ioredis": "^5.8.0", 40 | "itscalledsoccer": "^1.0.2", 41 | "next": "^15.5.9", 42 | "next-seo": "^6.8.0", 43 | "next-transpile-modules": "^10.0.1", 44 | "node-gzip": "^1.1.2", 45 | "react": "19.1.1", 46 | "react-dom": "19.1.1", 47 | "react-use-cookie": "^1.6.1", 48 | "swr": "^2.3.6" 49 | }, 50 | "devDependencies": { 51 | "@types/jest": "^30.0.0", 52 | "@types/node": "^24.6.0", 53 | "@types/node-gzip": "^1.1.3", 54 | "@types/react": "19.1.15", 55 | "@typescript-eslint/eslint-plugin": "^8.45.0", 56 | "@typescript-eslint/parser": "^8.45.0", 57 | "csslint": "^1.0.5", 58 | "devmoji": "~2.3", 59 | "eslint": "^9.36.0", 60 | "eslint-config-next": "15.5.4", 61 | "lint-staged": "^16.2.3", 62 | "npm-run-all": "^4.1.5", 63 | "prettier": "^3.6.2", 64 | "ts-jest": "^29.4.4", 65 | "typescript": "^5.9.2" 66 | }, 67 | "engines": { 68 | "node": "22.x" 69 | }, 70 | "resolutions": { 71 | "d3-color": "d3-color-1-fix", 72 | "react": "19.1.1", 73 | "react-dom": "19.1.1", 74 | "@types/react": "19.1.15" 75 | }, 76 | "overrides": { 77 | "react": "19.1.1", 78 | "react-dom": "19.1.1", 79 | "@types/react": "19.1.15" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /utils/getAllFixtureIds.ts: -------------------------------------------------------------------------------- 1 | import { parseISO } from "date-fns"; 2 | import { getMatchTitle } from "./getFormattedValues"; 3 | 4 | export type SlimMatch = { 5 | fixtureId: number; 6 | title: string; 7 | status: Results.Fixture["status"]; 8 | date: string; 9 | }; 10 | export type MatchWithTeams = { 11 | fixtureId: number; 12 | title: string; 13 | status: Results.Fixture["status"]; 14 | date: Date; 15 | home: string; 16 | away: string; 17 | }; 18 | export default function getAllFixtureIds( 19 | data: Results.ParsedData, 20 | filterTeam?: string, 21 | ): SlimMatch[] { 22 | return Object.entries(data.teams).reduce( 23 | (acc: SlimMatch[], [team, matches]) => { 24 | if (filterTeam && team !== filterTeam) { 25 | return acc; 26 | } 27 | return [ 28 | ...acc, 29 | ...matches 30 | .filter( 31 | (match) => 32 | !acc.some(({ fixtureId }) => match.fixtureId === fixtureId), 33 | ) 34 | .map((match) => ({ 35 | fixtureId: match.fixtureId, 36 | date: match.rawDate, 37 | title: getMatchTitle(match), 38 | status: match.status, 39 | })), 40 | ]; 41 | }, 42 | [], 43 | ); 44 | } 45 | 46 | export function getAllFixtures( 47 | data: Results.ParsedData, 48 | filter: (match: Results.Match) => boolean = () => true, 49 | ): MatchWithTeams[] { 50 | return Object.entries(data.teams).reduce( 51 | (acc: MatchWithTeams[], [, matches]) => { 52 | return [ 53 | ...acc, 54 | ...matches 55 | .filter( 56 | (match) => 57 | !acc.some(({ fixtureId }) => match.fixtureId === fixtureId), 58 | ) 59 | .filter(filter) 60 | .map((match) => ({ 61 | fixtureId: match.fixtureId, 62 | home: match.home ? match.team : match.opponent, 63 | away: !match.home ? match.team : match.opponent, 64 | date: parseISO(match.rawDate), 65 | title: getMatchTitle(match), 66 | status: match.status, 67 | })), 68 | ]; 69 | }, 70 | [], 71 | ); 72 | } 73 | 74 | export function getAllUniqueFixtures< 75 | M extends Results.Match, 76 | Data extends { 77 | teams: Record; 78 | }, 79 | >(data: Data): M[] { 80 | return Object.values(data.teams).reduce((acc: M[], matches) => { 81 | return [ 82 | ...acc, 83 | ...matches.filter( 84 | (match) => !acc.some(({ fixtureId }) => match.fixtureId === fixtureId), 85 | ), 86 | ]; 87 | }, []); 88 | } 89 | -------------------------------------------------------------------------------- /components/KBar/Provider.tsx: -------------------------------------------------------------------------------- 1 | import { LeagueOptions } from "@/utils/Leagues"; 2 | import NavigationConfig from "@/constants/nav"; 3 | import type { NavItem } from "@/constants/nav"; 4 | import { Action, KBarProvider, KBarProviderProps } from "@refinedev/kbar"; 5 | import { NextRouter, useRouter } from "next/router"; 6 | import React, { useContext } from "react"; 7 | import { PropsWithChildren } from "react"; 8 | import LeagueContext from "../Context/League"; 9 | 10 | const getActions = ({ 11 | router, 12 | league, 13 | onSetLeague, 14 | }: { 15 | router: NextRouter; 16 | league: Results.Leagues; 17 | onSetLeague: (league: Results.Leagues) => void; 18 | }): KBarProviderProps["actions"] => [ 19 | ...NavigationConfig.filter( 20 | (action) => 21 | typeof action === "object" && Boolean((action as NavItem)?.href), 22 | ).map((action): Action => { 23 | const navItem = action as NavItem; 24 | return { 25 | id: navItem.href || navItem.title, 26 | name: navItem.title, 27 | icon: navItem.icon as unknown as React.ReactNode, 28 | section: navItem.group?.description, 29 | perform: () => { 30 | if (navItem.href.includes("http") && typeof window !== "undefined") { 31 | window.location.href = navItem.href; 32 | } else { 33 | router.push({ 34 | pathname: navItem.external 35 | ? navItem.href 36 | : `/${league}${navItem.href}`, 37 | }); 38 | } 39 | }, 40 | }; 41 | }), 42 | ...Object.entries(LeagueOptions).map(([l, leagueName]) => { 43 | return { 44 | id: `select-${l}`, 45 | name: `Select League: ${leagueName}`, 46 | section: "Select League", 47 | perform: () => { 48 | onSetLeague(l as Results.Leagues); 49 | router.push({ 50 | pathname: router.pathname, 51 | query: { 52 | ...router.query, 53 | league: l, 54 | }, 55 | }); 56 | }, 57 | }; 58 | }), 59 | ]; 60 | 61 | export type ProviderProps = KBarProviderProps & { 62 | onSetLeague: (league: Results.Leagues) => void; 63 | } & PropsWithChildren; 64 | 65 | const Provider = React.forwardRef( 66 | function Provider(props) { 67 | const router = useRouter(); 68 | const league = useContext(LeagueContext); 69 | 70 | return ( 71 | 79 | ); 80 | }, 81 | ); 82 | 83 | export default Provider; 84 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "27 22 * * 0" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["javascript"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /pages/[league]/since-result/away/[result].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { isBefore, parseISO } from "date-fns"; 4 | import { useRouter } from "next/router"; 5 | 6 | const formattedResults: Record = { 7 | D: "draw", 8 | L: "loss", 9 | W: "win", 10 | }; 11 | 12 | export default function SinceResultPage(): React.ReactElement { 13 | const router = useRouter(); 14 | const result: Results.ResultTypes[] = (router.query.result 15 | ?.toString() 16 | ?.split(",") as Results.ResultTypes[]) || ["W"]; 17 | return ( 18 | formattedResults[r]) 21 | .join(" or ")}`} 22 | dataParser={(teams) => dataParser(teams, result)} 23 | /> 24 | ); 25 | } 26 | 27 | function dataParser( 28 | prefilteredData: Results.ParsedData["teams"], 29 | resultTypes: Results.ResultTypes[], 30 | ): Render.RenderReadyData { 31 | const lastTeamResult: Record = {}; 32 | const data = Object.keys(prefilteredData).reduce( 33 | (acc, team) => { 34 | acc[team] = prefilteredData[team].filter((match) => !match.home); 35 | return acc; 36 | }, 37 | {} as Results.ParsedData["teams"], 38 | ); 39 | return Object.keys(data).map((team) => [ 40 | team, 41 | ...data[team] 42 | .sort((a, b) => (new Date(a.date) > new Date(b.date) ? 1 : -1)) 43 | .map((match, idx) => { 44 | if (typeof lastTeamResult[team] === "undefined") { 45 | lastTeamResult[team] = 0; 46 | } 47 | if ( 48 | resultTypes.filter( 49 | (result) => match.result?.toLowerCase() === result.toLowerCase(), 50 | ).length > 0 51 | ) { 52 | lastTeamResult[team] = idx; 53 | } 54 | const lastResult = [...data[team]] 55 | .reverse() 56 | .find( 57 | (m) => 58 | resultTypes.includes(m.result as Results.ResultTypes) && 59 | isBefore(parseISO(m.rawDate), parseISO(match.rawDate)), 60 | ); 61 | const lastResultIdx = data[team].findIndex( 62 | (m) => m.fixtureId === lastResult?.fixtureId, 63 | ); 64 | return ( 65 | 69 | typeof lastTeamResult[team] !== "undefined" && 70 | (match.result || data[team][idx - 1]?.result) 71 | ? idx - lastResultIdx - 1 72 | : "-" 73 | } 74 | /> 75 | ); 76 | }), 77 | ]); 78 | } 79 | -------------------------------------------------------------------------------- /pages/[league]/since-result/home/[result].tsx: -------------------------------------------------------------------------------- 1 | import BaseGridPage from "@/components/BaseGridPage"; 2 | import MatchCell from "@/components/MatchCell"; 3 | import { isBefore, parseISO } from "date-fns"; 4 | import { useRouter } from "next/router"; 5 | 6 | const formattedResults: Record = { 7 | D: "draw", 8 | L: "loss", 9 | W: "win", 10 | }; 11 | 12 | export default function SinceResultPage(): React.ReactElement { 13 | const router = useRouter(); 14 | const result: Results.ResultTypes[] = (router.query.result 15 | ?.toString() 16 | ?.split(",") as Results.ResultTypes[]) || ["W"]; 17 | return ( 18 | formattedResults[r]) 21 | .join(" or ")}`} 22 | dataParser={(teams) => dataParser(teams, result)} 23 | /> 24 | ); 25 | } 26 | 27 | function dataParser( 28 | prefilteredData: Results.ParsedData["teams"], 29 | resultTypes: Results.ResultTypes[], 30 | ): Render.RenderReadyData { 31 | const lastTeamResult: Record = {}; 32 | const data = Object.keys(prefilteredData).reduce( 33 | (acc, team) => { 34 | acc[team] = prefilteredData[team].filter((match) => match.home); 35 | return acc; 36 | }, 37 | {} as Results.ParsedData["teams"], 38 | ); 39 | return Object.keys(data).map((team) => [ 40 | team, 41 | ...data[team] 42 | .sort((a, b) => (new Date(a.date) > new Date(b.date) ? 1 : -1)) 43 | .map((match, idx) => { 44 | if (typeof lastTeamResult[team] === "undefined") { 45 | lastTeamResult[team] = 0; 46 | } 47 | if ( 48 | resultTypes.filter( 49 | (result) => match.result?.toLowerCase() === result.toLowerCase(), 50 | ).length > 0 51 | ) { 52 | lastTeamResult[team] = idx; 53 | } 54 | const lastResult = [...data[team]] 55 | .reverse() 56 | .find( 57 | (m) => 58 | resultTypes.includes(m.result as Results.ResultTypes) && 59 | isBefore(parseISO(m.rawDate), parseISO(match.rawDate)), 60 | ); 61 | const lastResultIdx = data[team].findIndex( 62 | (m) => m.fixtureId === lastResult?.fixtureId, 63 | ); 64 | return ( 65 | 69 | typeof lastTeamResult[team] !== "undefined" && 70 | (match.result || data[team][idx - 1]?.result) 71 | ? idx - lastResultIdx - 1 72 | : "-" 73 | } 74 | /> 75 | ); 76 | }), 77 | ]); 78 | } 79 | -------------------------------------------------------------------------------- /types/api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace FormGuideAPI { 2 | type BaseAPI> = { 3 | data: T; 4 | errors: { message: string; [key: string]: string }[]; 5 | meta: U & Meta.Generic; 6 | }; 7 | type BaseAPIV2> = { 8 | data: T; 9 | errors?: never[]; 10 | meta: U & Meta.Generic; 11 | }; 12 | namespace Responses { 13 | type ErrorResponse = { 14 | errors: { message: string; [key: string]: string }[]; 15 | }; 16 | type GoalsEndpoint = BaseAPIV2; 17 | type StatsEndpoint = BaseAPIV2; 18 | type FixtureEndpoint = BaseAPIV2; 19 | type FixturesEndpoint = BaseAPIV2>; 20 | type PlusMinusEndpoint = BaseApiV2; 21 | type SimulationsEndpoint = BaseAPIV2; 22 | type PlayerMinutesEndpoint = BaseAPIV2; 23 | } 24 | namespace Data { 25 | type GoalsEndpoint = { 26 | teams: Record; 27 | }; 28 | type StatsEndpoint = { 29 | teams: Record; 30 | }; 31 | type DetailedEndpoint = { 32 | teams: Record; 33 | }; 34 | type Fixture = { 35 | fixtureData: Results.FixtureApi[]; 36 | predictionData: Results.PredictionApi[]; 37 | }; 38 | type Fixtures = Results.FixtureApi; 39 | type GoalMatch = Results.MatchWithGoalData; 40 | type StatsMatch = Results.MatchWithStatsData; 41 | type DetailedMatch = Results.Match & { fixtureData: Results.FixtureApi[] }; 42 | type Simulations = Record>; 43 | type PlusMinusEndpoint = Record>; 44 | type PlusMinus = { 45 | onGF: number; 46 | offGF: number; 47 | onGA: number; 48 | offGA: number; 49 | 50 | minutes: number; 51 | matches: number; 52 | }; 53 | type PlayerMinutesEndpoint = { 54 | fixture: Results.Fixture; 55 | fixtureId: number; 56 | date: string; 57 | rawDate: string; 58 | score: Results.FixtureApi["score"]; 59 | teams: Results.FixtureApi["teams"]; 60 | goals: Results.FixtureEvent[]; 61 | playerMinutes: { 62 | id: number; 63 | name: string; 64 | photo: string; 65 | minutes: number | null; 66 | substitute: boolean; 67 | on: number | null; 68 | off: number | null; 69 | }[]; 70 | }; 71 | } 72 | namespace Meta { 73 | type Generic = { fromCache?: boolean; took?: number; compressed?: boolean }; 74 | type Simulations = Generic & { simulations: number }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /functions/src/form.ts: -------------------------------------------------------------------------------- 1 | import { http } from "@google-cloud/functions-framework"; 2 | import { config } from "dotenv"; 3 | import fetch from "node-fetch-commonjs"; 4 | 5 | import { 6 | fetchCachedOrFresh, 7 | fetchCachedOrFreshV2, 8 | getEndpoint, 9 | getExpires, 10 | parseRawData, 11 | thisYear, 12 | } from "./utils"; 13 | 14 | config(); 15 | 16 | const URL_BASE = `https://${process.env.API_FOOTBALL_BASE}`; 17 | const REDIS_URL = process.env.REDIS_URL; 18 | const API_BASE = process.env.API_FOOTBALL_BASE; 19 | const API_KEY = process.env.API_FOOTBALL_KEY; 20 | const APP_VERSION = process.env.APP_VERSION || "v2.0.4"; 21 | const defaultLeague: Results.Leagues = "mls"; 22 | 23 | http("form", async (req, res) => { 24 | res.header("Content-Type", "application/json"); 25 | const year = req.query.year ? Number(req.query.year) : thisYear; 26 | const league: Results.Leagues = req.query.league 27 | ? (String(req.query.league).slice(0, 32) as Results.Leagues) 28 | : defaultLeague; 29 | try { 30 | const data = await fetchData({ year, league }); 31 | res.setHeader( 32 | `Cache-Control`, 33 | `s-maxage=${getExpires(year, data)}, stale-while-revalidate`, 34 | ); 35 | res.json({ 36 | data, 37 | }); 38 | } catch (e) { 39 | console.error(e); 40 | res.json({ 41 | errors: [e], 42 | }); 43 | } 44 | }); 45 | 46 | async function fetchData({ 47 | year, 48 | league = "mls", 49 | }: { 50 | year: number; 51 | league?: Results.Leagues; 52 | }): Promise { 53 | if ( 54 | typeof REDIS_URL !== "string" || 55 | typeof API_BASE !== "string" || 56 | typeof API_KEY !== "string" 57 | ) { 58 | console.error("Missing environment variables"); 59 | throw "Application not properly configured"; 60 | } 61 | 62 | // keys differentiate by year and league 63 | const redisKey = `formguide:${APP_VERSION}:${league}:${year}`; 64 | const { data: matchData } = await fetchCachedOrFreshV2( 65 | redisKey, 66 | async (): Promise => { 67 | const headers = { 68 | "x-rapidapi-host": API_BASE, 69 | "x-rapidapi-key": API_KEY, 70 | useQueryString: "true", 71 | }; 72 | // cache for four weeks if it's not the current year. no need to hit the API 73 | const response = await fetch(`${URL_BASE}${getEndpoint(year, league)}`, { 74 | headers, 75 | }); 76 | return parseRawData((await response.json()) as Results.RawData); 77 | }, 78 | (data) => getExpires(year, data), 79 | { 80 | allowCompression: true, 81 | }, 82 | ).catch((e) => { 83 | console.error("Error fetching data", e); 84 | throw e; 85 | }); 86 | if (!matchData) { 87 | throw "no data found"; 88 | } 89 | return matchData; 90 | } 91 | -------------------------------------------------------------------------------- /pages/[league]/game-days/since/[period].tsx: -------------------------------------------------------------------------------- 1 | import { differenceInDays } from "date-fns"; 2 | 3 | import { useRouter } from "next/router"; 4 | 5 | import ColorKey from "@/components/ColorKey"; 6 | import BaseRollingPage from "@/components/BaseRollingPage"; 7 | import { getArrayAverage } from "@/utils/array"; 8 | import { 9 | PeriodLengthOptions, 10 | usePeriodLength, 11 | } from "@/components/Toggle/PeriodLength"; 12 | 13 | export default function Chart(): React.ReactElement { 14 | const router = useRouter(); 15 | const { period = 5 } = router.query; 16 | const defaultPeriodLength: PeriodLengthOptions = 17 | +period.toString() > 0 && +period.toString() < 34 ? +period.toString() : 5; 18 | 19 | const { value: periodLength, renderComponent } = usePeriodLength( 20 | defaultPeriodLength, 21 | true, 22 | ); 23 | return ( 24 | 30 | typeof pointValue !== "number" 31 | ? "background.paper" 32 | : pointValue > 8 33 | ? "warning.main" 34 | : pointValue < 5.5 35 | ? "error.main" 36 | : "success.main" 37 | } 38 | isWide 39 | > 40 | 45 | 46 | ); 47 | } 48 | 49 | function parseChartData( 50 | teams: Results.ParsedData["teams"], 51 | periodLength = 5, 52 | ): ReturnType { 53 | return Object.keys(teams) 54 | .sort() 55 | .map((team) => { 56 | return [ 57 | team, 58 | ...teams[team] 59 | .slice(0, teams[team].length - periodLength) 60 | .map((_, idx) => { 61 | const resultSet = teams[team] 62 | .sort((a, b) => { 63 | return new Date(a.date) > new Date(b.date) ? 1 : -1; 64 | }) 65 | .slice(idx, idx + periodLength) 66 | .filter((match) => match.result !== null); 67 | const results = resultSet.map((match, matchIdx) => { 68 | return teams[team][idx - 1]?.date 69 | ? differenceInDays( 70 | new Date(teams[team][idx + matchIdx].date), 71 | new Date(teams[team][idx + matchIdx - 1].date), 72 | ) 73 | : 0; 74 | }); 75 | const value = 76 | results.length !== periodLength ? null : getArrayAverage(results); 77 | return { value, matches: resultSet }; 78 | }), 79 | ]; 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /components/BaseASARollingPage.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/Home.module.css"; 2 | import MatchGrid from "@/components/MatchGrid"; 3 | import { format } from "util"; 4 | import BaseASADataPage from "./BaseASADataPage"; 5 | import { BasePageProps } from "./BasePage"; 6 | import { Options, useHomeAway } from "./Toggle/HomeAwayToggle"; 7 | import { 8 | useResultToggleAll, 9 | OptionsAll as ResultOptions, 10 | } from "./Toggle/ResultToggle"; 11 | import { Box } from "@mui/system"; 12 | 13 | export type DataParserProps = { 14 | periodLength: number; 15 | data: T; 16 | getBackgroundColor: Render.GetBackgroundColor; 17 | isStaticHeight: boolean; 18 | isWide: boolean; 19 | stat: ASA.ValidStats; 20 | homeAway: Options; 21 | result?: ResultOptions; 22 | }; 23 | 24 | export default function BaseASARollingPage({ 25 | renderControls, 26 | endpoint, 27 | pageTitle, 28 | periodLength, 29 | dataParser, 30 | children, 31 | getBackgroundColor = () => "success.main", 32 | isStaticHeight = true, 33 | isWide = false, 34 | stat, 35 | }: React.PropsWithChildren<{ 36 | renderControls?: BasePageProps["renderControls"]; 37 | endpoint: ASA.Endpoint; 38 | pageTitle: string; 39 | periodLength: number; 40 | dataParser: (data: DataParserProps) => Render.RenderReadyData; 41 | getBackgroundColor?: Render.GetBackgroundColor; 42 | isStaticHeight?: boolean; 43 | isWide?: boolean; 44 | stat: ASA.ValidStats; 45 | }>): React.ReactElement { 46 | const { value: homeAway, renderComponent: renderHomeAwayToggle } = 47 | useHomeAway(); 48 | const { value: resultToggle, renderComponent: renderResultToggle } = 49 | useResultToggleAll(); 50 | return ( 51 | 52 | renderControls={() => ( 53 | 54 | {renderHomeAwayToggle()} 55 | Result: {renderResultToggle()} 56 | {renderControls && renderControls()} 57 | 58 | )} 59 | endpoint={endpoint} 60 | pageTitle={format(pageTitle, periodLength)} 61 | renderComponent={(data) => 62 | data ? ( 63 | 69 | dataParser({ 70 | periodLength, 71 | getBackgroundColor, 72 | data, 73 | isStaticHeight, 74 | isWide, 75 | stat, 76 | homeAway, 77 | result: resultToggle, 78 | }) 79 | } 80 | showMatchdayHeader={false} 81 | /> 82 | ) : ( 83 | <> 84 | ) 85 | } 86 | > 87 | {children} 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /pages/[league]/facts/form.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, Typography } from "@mui/material"; 2 | import BaseDataPage from "@/components/BaseDataPage"; 3 | 4 | export default function FormFacts(): React.ReactElement { 5 | return ( 6 | { 9 | return ( 10 | data && ( 11 | <> 12 | 13 | 14 | Most matches without winning 15 | 16 |
    17 | {getMostMatchesWithResult(data.teams, ["D", "L"]).map( 18 | (data, idx) => ( 19 |
  • 20 | {data.team} - {data.matches} 21 |
  • 22 | ), 23 | )} 24 |
25 |
26 | 27 | 28 | 29 | Most matches without losing 30 | 31 |
    32 | {getMostMatchesWithResult(data.teams, ["W", "D"]).map( 33 | (data, idx) => ( 34 |
  • 35 | {data.team} - {data.matches} 36 |
  • 37 | ), 38 | )} 39 |
40 |
41 | 42 | 43 | 44 | Most matches without drawing 45 | 46 |
    47 | {getMostMatchesWithResult(data.teams, ["W", "L"]).map( 48 | (data, idx) => ( 49 |
  • 50 | {data.team} - {data.matches} 51 |
  • 52 | ), 53 | )} 54 |
55 |
56 | 57 | ) 58 | ); 59 | }} 60 | /> 61 | ); 62 | } 63 | 64 | // Returns biggest streak of consecutive matches without winning 65 | function getMostMatchesWithResult( 66 | results: Results.ParsedData["teams"], 67 | resultType: Results.ResultType[], 68 | ): { 69 | team: keyof Results.ParsedData["teams"]; 70 | matches: number; 71 | }[] { 72 | const teams = Object.keys(results); 73 | return teams.sort().map((team) => { 74 | const matches = results[team]; 75 | let maxStreak = 0; 76 | let currentStreak = 0; 77 | 78 | for (const match of matches) { 79 | if (resultType.includes(match.result)) { 80 | currentStreak++; 81 | } else { 82 | maxStreak = Math.max(maxStreak, currentStreak); 83 | currentStreak = 0; 84 | } 85 | } 86 | 87 | // Check at the end of the loop 88 | maxStreak = Math.max(maxStreak, currentStreak); 89 | 90 | return { team, matches: maxStreak }; 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /pages/[league]/record/since/[date].tsx: -------------------------------------------------------------------------------- 1 | import BaseDataPage from "@/components/BaseDataPage"; 2 | import { getRecord, getRecordPoints, RecordPoints } from "@/utils/getRecord"; 3 | import { Box, Button, FormLabel, Input } from "@mui/material"; 4 | import { isAfter, parseISO } from "date-fns"; 5 | import { useRouter } from "next/router"; 6 | import { useState } from "react"; 7 | 8 | export default function RecordSinceDate(): React.ReactElement { 9 | const router = useRouter(); 10 | const { date } = router.query; 11 | const [sort, setSort] = useState<"points" | "alpha">("points"); 12 | return date ? ( 13 | { 15 | return ( 16 | <> 17 | 18 | Select a date 19 | { 24 | if (ev.target.value) { 25 | router.push({ 26 | pathname: router.basePath, 27 | query: { ...router.query, date: ev.target.value }, 28 | }); 29 | } 30 | }} 31 | /> 32 | 33 | 34 | Sort 35 | 36 | 37 | 38 | 39 | ); 40 | }} 41 | pageTitle={`Record since ${date}`} 42 | renderComponent={(data) => { 43 | const parsedDate = new Date(date?.toString()); 44 | const records = Object.keys(data.teams).map( 45 | (team): [string, RecordPoints, number] => { 46 | const record = getRecord( 47 | data.teams[team].filter((match) => 48 | isAfter(parseISO(match.rawDate), parsedDate), 49 | ), 50 | ); 51 | return [team, record, getRecordPoints(record)]; 52 | }, 53 | ); 54 | return ( 55 |
    56 | {records 57 | .sort( 58 | sort === "points" 59 | ? (a, b) => { 60 | return a[2] < b[2] ? 1 : a[2] > b[2] ? -1 : 0; 61 | } 62 | : undefined, 63 | ) 64 | .map( 65 | ( 66 | [team, record, points]: [string, RecordPoints, number], 67 | idx, 68 | ) => { 69 | return ( 70 |
  • 71 | {team} ({points}) — {record[0]}– 72 | {record[1]}–{record[2]} 73 |
  • 74 | ); 75 | }, 76 | )} 77 |
78 | ); 79 | }} 80 | /> 81 | ) : ( 82 | <> 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /components/Rolling/Base.tsx: -------------------------------------------------------------------------------- 1 | import { DataPageProps } from "@/components/BaseDataPage"; 2 | import { BasePageProps } from "../BasePage"; 3 | import { useHomeAway } from "../Toggle/HomeAwayToggle"; 4 | import React from "react"; 5 | import RollingBoxV2, { NumberFormat } from "./BoxV2"; 6 | import AbstractBaseRollingPage from "./AbstractBase"; 7 | import { sortByDate } from "@/utils/sort"; 8 | 9 | export type BaseRollingPageProps = { 10 | pageTitle: string; 11 | getBackgroundColor?: (args: { 12 | periodLength: number; 13 | value: number | null; 14 | }) => string; 15 | getSummaryValue?: (values: ValueType[]) => number; 16 | getValue: (match: T) => ValueType | undefined; 17 | getEndpoint?: DataPageProps["getEndpoint"]; 18 | getBoxHeight?: (value: number | null, periodLength: number) => string; 19 | isWide?: boolean; 20 | numberFormat?: NumberFormat; 21 | max?: number; 22 | renderControls?: BasePageProps["renderControls"]; 23 | }; 24 | 25 | export default function BaseRollingPage< 26 | T extends Results.Match = 27 | | Results.Match 28 | | Results.MatchWithStatsData 29 | | Results.MatchWithGoalData, 30 | ValueType = number, 31 | >( 32 | props: React.PropsWithChildren>, 33 | ): React.ReactElement { 34 | const { value: homeAway, renderComponent: renderHomeAwayToggle } = 35 | useHomeAway(); 36 | return ( 37 | }, 41 | Record 42 | > 43 | getData={(data) => 44 | Object.keys(data.teams).reduce((acc, team: string) => { 45 | const matches = data.teams[team] as unknown as T[]; 46 | return { 47 | ...acc, 48 | [team]: 49 | typeof data.teams === "object" && data.teams 50 | ? matches.sort(sortByDate) 51 | : [], 52 | }; 53 | }, {}) 54 | } 55 | filterMatches={(m) => 56 | (homeAway === "all" ? true : m.home === (homeAway === "home")) && 57 | m.result !== null 58 | } 59 | renderBox={(item, periodLength) => { 60 | return ( 61 | 78 | value !== null ? value.toFixed(1) : "" 79 | : props.numberFormat 80 | } 81 | value={item.value} 82 | /> 83 | ); 84 | }} 85 | renderControls={() => <>{renderHomeAwayToggle()}} 86 | {...props} 87 | /> 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /utils/referee.ts: -------------------------------------------------------------------------------- 1 | import { getAllUniqueFixtures } from "./getAllFixtureIds"; 2 | import { sortByDate } from "./sort"; 3 | 4 | import { Options as HomeAwayOptions } from "@/components/Toggle/HomeAwayToggle"; 5 | import { RefereeStatOptions } from "@/components/Toggle/RefereeStats"; 6 | 7 | export function getRefereeFixtureData( 8 | data: FormGuideAPI.Data.StatsEndpoint, 9 | { 10 | homeAway, 11 | statType, 12 | }: { homeAway: HomeAwayOptions; statType: RefereeStatOptions }, 13 | ): Record< 14 | string, 15 | { match: FormGuideAPI.Data.StatsMatch; value: number | null }[] 16 | > { 17 | const referees: Record< 18 | string, 19 | { match: FormGuideAPI.Data.StatsMatch; value: number | null }[] 20 | > = {}; 21 | const fixtures = getAllUniqueFixtures< 22 | FormGuideAPI.Data.StatsMatch, 23 | FormGuideAPI.Data.StatsEndpoint 24 | >(data); 25 | fixtures.forEach((fixture) => { 26 | const refereeParsedName = getRefereeName(fixture.referee); 27 | 28 | if (!refereeParsedName) { 29 | return; 30 | } 31 | if (typeof referees[refereeParsedName] === "undefined") { 32 | referees[refereeParsedName] = []; 33 | } 34 | const teamValue = Number(fixture.stats?.[fixture.team]?.[statType] ?? 0); 35 | const oppValue = Number(fixture.stats?.[fixture.opponent]?.[statType] ?? 0); 36 | referees[refereeParsedName].push({ 37 | match: fixture, 38 | value: 39 | homeAway === "all" 40 | ? teamValue + oppValue 41 | : homeAway === "home" 42 | ? fixture.home 43 | ? teamValue 44 | : oppValue 45 | : fixture.home 46 | ? oppValue 47 | : teamValue, 48 | }); 49 | referees[refereeParsedName].sort((a, b) => { 50 | return sortByDate(a.match, b.match); 51 | }); 52 | }); 53 | return referees; 54 | } 55 | 56 | function getRefereeName(name: string): string { 57 | const refereeNameParts = 58 | name?.replace(/,.+/, "").split(" ").filter(Boolean) ?? []; 59 | 60 | if (!refereeNameParts.length) { 61 | return ""; 62 | } 63 | 64 | const [firstName, ...remainder] = refereeNameParts; 65 | return [`${firstName[0]}.`, ...remainder].join(" "); 66 | } 67 | 68 | export function getStatBackgroundColor( 69 | statType: RefereeStatOptions, 70 | value: number | null, 71 | { homeAway }: { homeAway: HomeAwayOptions }, 72 | ) { 73 | if (!value) { 74 | return "background.default"; 75 | } 76 | switch (statType) { 77 | case "Yellow Cards": 78 | default: 79 | return value && value >= (homeAway === "all" ? 7 : 3.5) 80 | ? "error.main" 81 | : value <= (homeAway === "all" ? 3 : 1.5) 82 | ? "success.main" 83 | : "warning.main"; 84 | case "Red Cards": 85 | return value && value >= 1 86 | ? "error.main" 87 | : value < 1 88 | ? "success.main" 89 | : "warning.main"; 90 | case "Fouls": 91 | return value && value >= (homeAway === "all" ? 30 : 15) 92 | ? "error.main" 93 | : value <= (homeAway === "all" ? 20 : 10) 94 | ? "success.main" 95 | : "warning.main"; 96 | } 97 | } 98 | --------------------------------------------------------------------------------