├── .env.default ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codacy.yml │ ├── codeql-analysis.yml │ ├── gcp-functions-deploy.yml │ └── test.yml ├── .gitignore ├── .husky ├── pre-commit └── prepare-commit-msg ├── .nvmrc ├── .prettierrc ├── .stylelintrc ├── .vercelignore ├── .versionrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── components ├── App │ ├── Bar.tsx │ └── LeagueSelect.tsx ├── BaseASADataPage.tsx ├── BaseASAGridPage.tsx ├── BaseASARollingPage.tsx ├── BaseDataPage.tsx ├── BaseGrid.tsx ├── BaseGridPage.tsx ├── BasePage.tsx ├── BaseRollingPage.tsx ├── Cell.tsx ├── Changelog.tsx ├── ColorKey.tsx ├── Context │ ├── DarkMode.tsx │ ├── Drawer.tsx │ ├── EasterEgg.tsx │ ├── League.tsx │ └── Year.tsx ├── DateFilter.tsx ├── EasterEgg.tsx ├── Fixtures │ └── FixtureListItem.tsx ├── Generic │ └── FormattedDate.tsx ├── Grid │ ├── Base.tsx │ ├── Cell.tsx │ ├── MatchDetails.tsx │ └── MatchGrid.tsx ├── KBar │ ├── Input.tsx │ └── Provider.tsx ├── MatchCell.tsx ├── MatchDescriptor.tsx ├── MatchGrid.tsx ├── MatchGridV2.tsx ├── Nav.tsx ├── Results.tsx ├── Rolling │ ├── AbstractBase.tsx │ ├── AbstractBox.tsx │ ├── AbstractGrid.tsx │ ├── Base.tsx │ ├── Box.tsx │ └── BoxV2.tsx ├── Selector │ └── Stats.tsx ├── Stats.tsx ├── Table.tsx ├── Toggle │ ├── HomeAwayToggle.tsx │ ├── OpponentToggle.tsx │ ├── PeriodLength.tsx │ ├── RefereeStats.tsx │ ├── ResultToggle.tsx │ ├── RollingToggle.tsx │ └── Toggle.tsx ├── VersusGrid.tsx └── XYChartTools.tsx ├── constants └── nav.ts ├── data ├── 2012.json ├── 2013.json ├── 2014.json ├── 2015.json ├── 2016.json ├── 2017.json ├── 2018.json ├── 2019.json ├── 2020.json ├── 2021.json └── mls │ └── all.json ├── functions ├── .env.default ├── .eslintignore ├── .eslintrc.json ├── .gcloudignore ├── .gitignore ├── .nvmrc ├── package-lock.json ├── package.json ├── src │ ├── constants.ts │ ├── football-api.ts │ ├── form.ts │ ├── index.ts │ ├── prediction.ts │ ├── types │ │ └── results.d.ts │ └── utils.ts └── tsconfig.json ├── jest.config.ts ├── lint-staged.config.js ├── middleware.ts ├── mls-data ├── 20220822_playerStats.csv └── 20220830_playerStats.csv ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── [league] │ ├── admin │ │ └── index.tsx │ ├── chart │ │ └── [period].tsx │ ├── facts │ │ ├── form.tsx │ │ ├── index.tsx │ │ ├── names-length.tsx │ │ └── names-order.tsx │ ├── first-goal │ │ ├── [type].tsx │ │ └── rolling │ │ │ └── [type] │ │ │ └── [period].tsx │ ├── fixtures │ │ ├── index.tsx │ │ └── today.tsx │ ├── ga-chart │ │ └── [period].tsx │ ├── ga │ │ ├── cumulative.tsx │ │ └── index.tsx │ ├── game-days │ │ ├── since-home.tsx │ │ ├── since.tsx │ │ └── since │ │ │ └── [period].tsx │ ├── game-states │ │ ├── comebacks.tsx │ │ ├── index.tsx │ │ ├── lost-leads.tsx │ │ └── team │ │ │ ├── [team].tsx │ │ │ └── index.tsx │ ├── gd-chart │ │ └── [period].tsx │ ├── gd │ │ ├── cumulative.tsx │ │ ├── index.tsx │ │ ├── team-by-half-conceded.tsx │ │ └── team-by-half.tsx │ ├── gf-chart │ │ └── [period].tsx │ ├── gf │ │ ├── cumulative.tsx │ │ └── index.tsx │ ├── index.tsx │ ├── odds │ │ └── index.tsx │ ├── player-minutes │ │ ├── [team] │ │ │ ├── index.tsx │ │ │ └── rolling.tsx │ │ └── index.tsx │ ├── plus-minus │ │ └── index.tsx │ ├── points │ │ ├── cumulative.tsx │ │ └── off-top.tsx │ ├── ppg │ │ ├── differential.tsx │ │ ├── opponent.tsx │ │ ├── outcomes.tsx │ │ └── team.tsx │ ├── projected-standings │ │ └── index.tsx │ ├── projected │ │ └── points.tsx │ ├── projections │ │ └── index.tsx │ ├── record │ │ └── since │ │ │ └── [date].tsx │ ├── referees │ │ └── index.tsx │ ├── results │ │ ├── first-half.tsx │ │ ├── halftime-after-drawing.tsx │ │ ├── halftime-after-leading.tsx │ │ ├── halftime-after-losing.tsx │ │ ├── halftime │ │ │ └── points │ │ │ │ └── [result].tsx │ │ └── second-half.tsx │ ├── since-result │ │ ├── [result].tsx │ │ ├── away │ │ │ └── [result].tsx │ │ ├── home │ │ │ └── [result].tsx │ │ └── opponent │ │ │ └── index.tsx │ ├── stats │ │ ├── [type].tsx │ │ ├── comparison │ │ │ └── [type].tsx │ │ ├── finishing │ │ │ └── index.tsx │ │ ├── rolling │ │ │ ├── [type] │ │ │ │ └── index.tsx │ │ │ └── finishing.tsx │ │ └── scatter │ │ │ └── [type].tsx │ ├── substitutes │ │ ├── earliest-multiple.tsx │ │ ├── earliest-rolling.tsx │ │ ├── earliest.tsx │ │ └── most-at-once.tsx │ ├── table │ │ ├── advanced.tsx │ │ ├── chart.tsx │ │ ├── index.tsx │ │ └── position.tsx │ ├── versus │ │ ├── gd.tsx │ │ ├── index.tsx │ │ └── record.tsx │ └── xg │ │ ├── against.tsx │ │ ├── difference.tsx │ │ ├── for.tsx │ │ └── rolling │ │ └── [stat] │ │ └── [period].tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── admin │ │ ├── all-fixtures.ts │ │ └── load-fixtures.ts │ ├── asa │ │ └── xg.ts │ ├── fixture │ │ └── [fixture].ts │ ├── fixtures │ │ └── [fixture].ts │ ├── form.ts │ ├── goals │ │ └── [league].ts │ ├── player-stats │ │ └── minutes.ts │ ├── players │ │ └── [league] │ │ │ └── [team].ts │ ├── plus-minus │ │ └── [league].ts │ ├── projected-standings │ │ └── index.ts │ ├── stats │ │ └── [league].ts │ └── theodds │ │ └── [league].ts ├── changelog.tsx ├── fixtures │ └── [id].tsx ├── index.tsx └── mls-player-stats │ └── minutes.tsx ├── public ├── ball.png ├── favicon.ico └── vercel.svg ├── pull_request_template.md ├── styles ├── Home.module.css └── globals.css ├── tsconfig.json ├── types ├── api.d.ts ├── asa.d.ts ├── calculate-correlation.d.ts ├── itscalledsoccer.d.ts ├── player-stats.d.ts ├── render.d.ts ├── results.d.ts └── theodds.d.ts └── utils ├── LeagueCodes.ts ├── LeagueConferences.ts ├── Leagues.ts ├── __tests__ └── cache-test.ts ├── api └── getFixtureData.ts ├── array.ts ├── cache.ts ├── cache └── getKeys.ts ├── data.ts ├── fetcher.ts ├── gameStates.ts ├── getAllFixtureIds.ts ├── getConsecutiveGames.tsx ├── getExpires.ts ├── getFormattedValues.ts ├── getGoals.ts ├── getLinks.ts ├── getMatchPoints.ts ├── getMatchResultString.ts ├── getPpg.ts ├── getRecord.tsx ├── getTeamPoints.ts ├── isLeagueAllowed.ts ├── match.ts ├── redis.ts ├── referee.ts ├── results.ts ├── sort.ts ├── table └── index.ts └── transform.ts /.env.default: -------------------------------------------------------------------------------- 1 | FORM_API= 2 | PREDICTIONS_API= 3 | THEODDS_API_KEY= 4 | 5 | REDIS_URL= 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: 16 24 | - run: npm ci 25 | - run: npm test 26 | 27 | - id: "auth" 28 | uses: "google-github-actions/auth@v0" 29 | with: 30 | credentials_json: "${{ secrets.GCP_CREDENTIALS }}" 31 | 32 | - id: "deploy-form" 33 | uses: "google-github-actions/deploy-cloud-functions@v0" 34 | with: 35 | name: "form" 36 | runtime: "nodejs16" 37 | region: "us-west3" 38 | source_dir: functions 39 | env_vars: REDIS_URL=${{secrets.REDIS_URL}},API_FOOTBALL_BASE=${{secrets.API_FOOTBALL_BASE}},API_FOOTBALL_KEY=${{secrets.API_FOOTBALL_KEY}} 40 | 41 | - id: "deploy-prediction" 42 | uses: "google-github-actions/deploy-cloud-functions@v0" 43 | with: 44 | name: "prediction" 45 | runtime: "nodejs16" 46 | region: "us-west3" 47 | source_dir: functions 48 | env_vars: REDIS_URL=${{secrets.REDIS_URL}},API_FOOTBALL_BASE=${{secrets.API_FOOTBALL_BASE}},API_FOOTBALL_KEY=${{secrets.API_FOOTBALL_KEY}} 49 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx devmoji -e --lint 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/Context/DarkMode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DarkMode = React.createContext(false); 4 | 5 | export default DarkMode; 6 | -------------------------------------------------------------------------------- /components/Context/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DrawerContext = React.createContext(true); 4 | 5 | export default DrawerContext; 6 | -------------------------------------------------------------------------------- /components/Context/EasterEgg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext(false); 4 | -------------------------------------------------------------------------------- /components/Context/League.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DEFAULT_LEAGUE = "mls"; 4 | export default React.createContext(DEFAULT_LEAGUE); 5 | -------------------------------------------------------------------------------- /components/Context/Year.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DEFAULT_YEAR = 2025; 4 | export default React.createContext(DEFAULT_YEAR); 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/Table.tsx: -------------------------------------------------------------------------------- 1 | import { getColumns, Row } from "@/utils/table"; 2 | import { 3 | DataGrid, 4 | DataGridProps, 5 | GridColDef, 6 | 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /functions/.env.default: -------------------------------------------------------------------------------- 1 | API_FOOTBALL_KEY= 2 | API_FOOTBALL_BASE= 3 | REDIS_URL= -------------------------------------------------------------------------------- /functions/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | ../ -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended"], 3 | "root": true, 4 | "rules": { 5 | "quotes": [1, "double", { "allowTemplateLiterals": true }] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ -------------------------------------------------------------------------------- /functions/.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "dependencies": { 4 | "@google-cloud/functions-framework": "^3.1.2", 5 | "date-fns": "^2.29.3", 6 | "dotenv": "^16.0.3", 7 | "express": "^4.18.2", 8 | "ioredis": "^5.2.3", 9 | "node-fetch-commonjs": "^3.2.4", 10 | "typescript": "^4.8.4" 11 | }, 12 | "devDependencies": { 13 | "@types/express": "^4.17.14", 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./prediction"; 2 | import "./form"; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | images: { 3 | domains: ["media.api-sports.io"], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /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.0", 24 | "@mui/icons-material": "^7.0.2", 25 | "@mui/material": "^7.0.2", 26 | "@mui/x-data-grid": "^7.29.0", 27 | "@refinedev/kbar": "^1.3.16", 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.3.11", 36 | "d3-color-1-fix": "*", 37 | "date-fns": "^4.1.0", 38 | "husky": "^9.1.7", 39 | "ioredis": "^5.6.1", 40 | "itscalledsoccer": "^1.0.2", 41 | "next": "^15.3.2", 42 | "next-seo": "^6.8.0", 43 | "next-transpile-modules": "^10.0.1", 44 | "node-gzip": "^1.1.2", 45 | "react": "18.3.1", 46 | "react-dom": "18.3.1", 47 | "react-use-cookie": "^1.6.1", 48 | "swr": "^2.3.3" 49 | }, 50 | "devDependencies": { 51 | "@types/jest": "^29.5.14", 52 | "@types/node": "^22.15.24", 53 | "@types/node-gzip": "^1.1.3", 54 | "@types/react": "18.3.23", 55 | "@typescript-eslint/eslint-plugin": "^8.33.0", 56 | "@typescript-eslint/parser": "^8.33.0", 57 | "csslint": "^1.0.5", 58 | "devmoji": "~2.3", 59 | "eslint": "^9.27.0", 60 | "eslint-config-next": "15.3.2", 61 | "lint-staged": "^16.1.0", 62 | "npm-run-all": "^4.1.5", 63 | "prettier": "^3.5.3", 64 | "ts-jest": "^29.3.4", 65 | "typescript": "^5.8.3" 66 | }, 67 | "engines": { 68 | "node": "22.x" 69 | }, 70 | "resolutions": { 71 | "d3-color": "d3-color-1-fix" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /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/[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 | matches.forEach((match) => { 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]/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]/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]/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]/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]/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]/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]/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]/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/[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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]/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 | -------------------------------------------------------------------------------- /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]/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]/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/[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]/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]/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]/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/[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]/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 | -------------------------------------------------------------------------------- /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]/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]/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]/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/[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]/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]/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 | -------------------------------------------------------------------------------- /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/_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/index.tsx: -------------------------------------------------------------------------------- 1 | import Home from "./[league]"; 2 | 3 | export default Home; 4 | -------------------------------------------------------------------------------- /public/ball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmontgomery/formguide/e1fc26155997fc0eb47ac3230812dc0ab79acba0/public/ball.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmontgomery/formguide/e1fc26155997fc0eb47ac3230812dc0ab79acba0/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /types/calculate-correlation.d.ts: -------------------------------------------------------------------------------- 1 | declare module "calculate-correlation" { 2 | function calculate(a: number[], b: number[]): number; 3 | export default calculate; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | const fetcher = (url: string) => fetch(url).then((r) => r.json()); 2 | 3 | export default fetcher; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------