├── .babelrc ├── .dockerignore ├── .env ├── .eslintrc.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── entrypoint.sh ├── hilde.png ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ ├── 20221221141310_create_tables │ │ └── migration.sql │ ├── 20230114104715_add_season_table │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public └── favicon.ico ├── src ├── components │ ├── Elements │ │ ├── AchievementList.tsx │ │ ├── Card.tsx │ │ ├── EloHistory.tsx │ │ ├── LeaderboardsTable.tsx │ │ ├── LoadingIndicator.tsx │ │ ├── MatchTable.tsx │ │ ├── Profile.tsx │ │ ├── RatingChange.tsx │ │ ├── Score.tsx │ │ ├── SeasonList.tsx │ │ ├── SeasonSelector.tsx │ │ ├── TeamDistance.tsx │ │ ├── TeamLink.tsx │ │ ├── TeamTable.tsx │ │ └── index.ts │ ├── Form │ │ ├── Input.tsx │ │ ├── MatchCreationForm.tsx │ │ ├── Select.tsx │ │ └── index.ts │ └── Layout │ │ ├── AdminLayout.tsx │ │ ├── Layout.tsx │ │ └── index.ts ├── middleware.ts ├── model │ ├── index.ts │ ├── leaderboards.ts │ └── team.ts ├── pages │ ├── _app.tsx │ ├── admin │ │ ├── index.tsx │ │ └── seasons.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── index.tsx │ ├── leaderboards.tsx │ ├── matches.tsx │ └── teams │ │ ├── [name].tsx │ │ └── index.tsx ├── server │ ├── context.ts │ ├── env.js │ ├── model │ │ ├── match.ts │ │ ├── season.ts │ │ └── team.ts │ ├── prisma.ts │ ├── routers │ │ ├── _app.ts │ │ ├── leaderboards.ts │ │ ├── matches.ts │ │ ├── seasons.ts │ │ └── teams.ts │ └── trpc.ts ├── styles │ └── globals.css └── utils │ ├── __tests__ │ └── achievements.test.ts │ ├── achievements.ts │ ├── elo.ts │ ├── store.ts │ ├── trpc.ts │ └── validation.ts ├── tailwind.config.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | "superjson-next" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Password for /admin 2 | ADMIN_PASSWORD=h1ldeb3steV3r 3 | 4 | # Arbitrary string used to generate secure tokens 5 | NEXTAUTH_SECRET=+Zrk5zW6fgog5k0LbN4bxL1YXKIhvb65Yln5ZKf+g3o= 6 | 7 | # Deployed URL 8 | NEXTAUTH_URL=http://localhost:3000 9 | 10 | # Database connection string 11 | DATABASE_URL=mysql://root:hildepw@localhost:3309/hilde 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [ 14.x ] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci && npm run lint && npm test && npm run build 28 | 29 | docker: 30 | runs-on: ubuntu-latest 31 | needs: build 32 | if: github.ref_type == 'tag' 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: docker/login-action@v2 36 | name: Login to Docker Hub 37 | with: 38 | username: ${{ secrets.DOCKER_USERNAME }} 39 | password: ${{ secrets.DOCKER_PASSWORD }} 40 | 41 | - uses: docker/build-push-action@v3 42 | name: Push to Docker Hub 43 | with: 44 | push: true 45 | tags: nehalist/hilde:latest 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea 4 | db.sqlite 5 | db.sqlite-journal 6 | 7 | # dependencies 8 | /node_modules 9 | /.pnp 10 | .pnp.js 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "bracketSpacing": true, 7 | "printWidth": 80, 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.3.1] - 2023-16-01 9 | 10 | ### Fixed 11 | 12 | - Fixed first match achievement condition 13 | 14 | ## [0.3.0] - 2023-15-01 15 | 16 | ### Added 17 | 18 | - Added season management 19 | - Added achievement deletion 20 | - Added duplicate match prevention 21 | - Added achievement tests 22 | - Added season selector 23 | 24 | ### Changed 25 | 26 | - Improved match deletion 27 | 28 | ### Fixed 29 | 30 | - Fix Underdog and similar achievements 31 | 32 | ## [0.2.0] - 2022-12-21 33 | 34 | ### Added 35 | - Added seasons 36 | - Added team profiles 37 | - Added achievements 38 | - Added dark mode 39 | - Added match table filtering 40 | - Added animations 41 | - Added tRPC 42 | - Added CHANGELOG 43 | - Added README 44 | - Added LICENSE 45 | - Added GitHub workflow 46 | - Added utility scripts (`utils:migrate-sqlite` and `teams:recalculate-ratings`) 47 | 48 | ### Changed 49 | - Switched from SQLite to MySQL 50 | - Improved React component structure 51 | - Changed elo `k` factor to be dynamic 52 | 53 | ### Fixed 54 | - Fixed OpenSSL alpine docker issue (by using `alpine:3.16` instead of `alpine:latest`) 55 | 56 | ### Removed 57 | - Removed old Statistics page 58 | - Removed monthly leaderboards (for now) 59 | 60 | ## [0.1.0] - 2022-04-27 61 | 62 | ### Added 63 | - Initial Release 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 1. Install dependencies only when needed 2 | FROM node:alpine3.16 AS deps 3 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 4 | RUN apk add --no-cache libc6-compat 5 | 6 | WORKDIR /app 7 | COPY package.json package-lock.json ./ 8 | RUN npm ci 9 | 10 | # 2. Rebuild the source code only when needed 11 | FROM node:alpine3.16 AS builder 12 | WORKDIR /app 13 | COPY --from=deps /app/node_modules ./node_modules 14 | COPY . . 15 | RUN npx next telemetry disable 16 | RUN npx prisma generate 17 | RUN NEXT_PUBLIC_SEASON=APP_NEXT_PUBLIC_SEASON npm run build 18 | 19 | # 3. Production image, copy all the files and run next 20 | FROM node:alpine3.16 AS runner 21 | 22 | WORKDIR /app 23 | 24 | ENV NODE_ENV=production 25 | 26 | RUN addgroup -g 1001 -S nodejs 27 | RUN adduser -S nextjs -u 1001 28 | 29 | # You only need to copy next.config.js if you are NOT using the default configuration 30 | COPY --from=builder /app/next.config.js ./ 31 | COPY --from=builder /app/public ./public 32 | COPY --from=builder /app/package.json ./package.json 33 | 34 | # Automatically leverage output traces to reduce image size 35 | # https://nextjs.org/docs/advanced-features/output-file-tracing 36 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 37 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 38 | COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma 39 | COPY --from=builder --chown=nextjs:nodejs /app/entrypoint.sh ./entrypoint.sh 40 | 41 | USER nextjs 42 | 43 | EXPOSE 3000 44 | 45 | ENV PORT 3000 46 | 47 | ENTRYPOINT ["/app/entrypoint.sh"] 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 nehalist.io 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 | # Hilde 🏆 2 | 3 | ![Build](https://img.shields.io/github/actions/workflow/status/nehalist/hilde/build.yml?branch=main) 4 | ![License](https://img.shields.io/github/license/nehalist/hilde) 5 | ![Docker Pulls](https://img.shields.io/docker/pulls/nehalist/hilde) 6 | 7 | You've got a foosball table or similar in your office and want to track your matches, player progress and compare yourself to your 8 | colleagues and see who's the best? You've come to the right place. 9 | **Hilde** is a match tracking app for games like foosball, table tennis, air hockey, etc. with achievements, elo ratings, statistics and 10 | more. **Hilde** is easy to setup and can be used by everyone. 11 | 12 | A public **demo** is available at [demo.hilde.gg](https://demo.hilde.gg). 13 | 14 | ![Hilde](hilde.png) 15 | 16 | ## Table of Contents 17 | 18 | 1. [Features](#features) - Hilde's features 19 | 2. [Getting Started](#getting-started) - How to get Hilde up and running 20 | 3. [Usage](#usage) - Command line utilities and configuration variables 21 | 4. [Contributing](#contributing) - How to contribute 22 | 5. [License](#license) 23 | 24 | ## ⚡️ Features 25 | 26 | - Simple, intuitive interface 27 | - **Elo rating** for each team 28 | - **Seasons** (managable via admin interface) 29 | - Detailed team statistics (winstreaks, winrate, elo history chart, ...) 30 | - **Achievements** (e.g. "Win 100 Matches", "Win 10 Matches in a row", ...) 31 | - Compare teams against each other 32 | - Teams of any size, simply separated by a comma in the team name 33 | - **Light/Dark theme** 34 | - Match comments 35 | - **Leaderboards** 36 | - *Optional*: Deployable for free with Vercel & Planetscale 37 | - *Optional*: Fully dockerized 38 | 39 | ## ⭐ Getting Started 40 | 41 | Hilde can be installed in a few minutes, either by deploying it to Vercel, using Docker or setting it up manually. 42 | 43 | Requirements: 44 | 45 | - Node 14 46 | - MySQL (5.7+) 47 | 48 | Keep in mind that after installing you need to add a season via the admin ui (`/admin`) using the password from the environment variable (`ADMIN_PASSWORD`). 49 | 50 | ### Free hosting with Vercel & Planetscale 51 | 52 | Hilde is designed in a way that it could easily be hosted for free, using [Vercel](https://vercel.com) for hosting 53 | and [Planetscale](https://planetscale.com) for the database. 54 | 55 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnehalist%2Fhilde) 56 | 57 | ### Docker Compose 58 | 59 | Example `docker-compose.yml`: 60 | 61 | ```yaml 62 | version: '3' 63 | 64 | services: 65 | hilde: 66 | depends_on: 67 | - database 68 | networks: 69 | - internal 70 | image: nehalist/hilde 71 | ports: 72 | - '127.0.0.1:3000:3000' 73 | environment: 74 | - DATABASE_URL=mysql://root:hildepw@database:3306/hilde 75 | - ADMIN_PASSWORD=v3rys3cr3tp4ssw0rd 76 | 77 | database: 78 | networks: 79 | - internal 80 | image: mysql:8.0 81 | environment: 82 | - MYSQL_DATABASE=hilde 83 | - MYSQL_ROOT_PASSWORD=hildepw 84 | volumes: 85 | - db:/var/lib/mysql 86 | 87 | volumes: 88 | db: 89 | 90 | networks: 91 | internal: 92 | ``` 93 | 94 | After running `docker-compose up -d` Hilde is running on `localhost:3000`. 95 | 96 | ### Manually (for development) 97 | 98 | 1. Clone/fork the repository 99 | 2. Run `npm ci` to install dependencies 100 | 3. Run `docker-compose up -d` in order to start the database container (or adjust the `.env` file to use a different db) 101 | 4. Run `npm run dev` to start the development server 102 | 5. Add awesome features. 103 | 104 | ### Docker 105 | 106 | The official Docker image of Hilde is available on [Docker Hub](https://hub.docker.com/repository/docker/nehalist/hilde). Run it locally 107 | via: 108 | 109 | 1. Run `docker run -p 127.0.0.1:3000:3000 -e DATABASE_URL=mysql://:@:/ nehalist/hilde` 110 | 2. Open `http://localhost:3000` 111 | 3. Done. 112 | 113 | ## ⚙️ Usage 114 | 115 | ### Administration 116 | 117 | Hilde provides an admin ui at `/admin` which can be used to manage seasons. 118 | 119 | ### Commands 120 | 121 | Hilde provides a set of utility terminal commands: 122 | 123 | | Command | Description | 124 | |-------------------|-------------------------------| 125 | | `npm run dev` | Starts the development server | 126 | | `npm run build` | Builds the app | 127 | | `npm run start` | Starts the production server | 128 | | `npm run lint` | Lints files | 129 | | `npm run migrate` | Executes Prisma migrations | 130 | 131 | ### Configuration 132 | 133 | Hilde can be configured via environment variables in the `.env` file. 134 | 135 | | Variable | Description | Default | 136 | |-------------------|----------------------------|------------------------------------------------| 137 | | `ADMIN_PASSWORD` | Administration password | `h1ldeb3steV3r` | 138 | | `NEXTAUTH_SECRET` | Token secret | `+Zrk5zW6fgog5k0LbN4bxL1YXKIhvb65Yln5ZKf+g3o=` | 139 | | `NEXTAUTH_URL` | Deployed URL of Hilde | `http://localhost:3000` | 140 | | `DATABASE_URL` | Database connection string | `mysql://root:hildepw@localhost:3309/hilde` | 141 | 142 | ## 👐 Contributing 143 | 144 | Hilde was created for fun and to play around with technologies I don't use on a daily basis in my office job, hence can be improved by many ways. 145 | 146 | It's built on: 147 | 148 | - [Next.js 13](https://nextjs.org/) 149 | - [tRPC](https://trpc.io/) 150 | - [Tailwind CSS](https://tailwindcss.com/) 151 | - [TypeScript](https://www.typescriptlang.org/) 152 | - [Prisma](https://www.prisma.io/) 153 | - [NextAuth.js](https://next-auth.js.org/) 154 | - and many more (see [package.json](package.json)) 155 | 156 | PRs are highly appreciated 🥳 157 | 158 | If you like Hilde, please consider starring the repository. Thanks! 159 | 160 | ## License 161 | 162 | Developed by [nehalist.io](https://nehalist.io). Licensed under the [MIT License](LICENSE). 163 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | database: 5 | container_name: hilde_db 6 | image: mysql:8.0 7 | environment: 8 | - MYSQL_DATABASE=hilde 9 | - MYSQL_ROOT_PASSWORD=hildepw 10 | ports: 11 | - "127.0.0.1:3309:3306" 12 | volumes: 13 | - db:/var/lib/mysql 14 | 15 | volumes: 16 | db: 17 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | test -n "$APP_ADMIN_PASSWORD" 4 | 5 | find /app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_ADMIN_PASSWORD#$ADMIN_PASSWORD#g" 6 | npx prisma migrate deploy 7 | node server.js 8 | -------------------------------------------------------------------------------- /hilde.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nehalist/hilde/8169f9acc8425faa03cc05f4d90f52074c5943e6/hilde.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | moduleNameMapper: { 6 | "~/(.*)": "/src/$1", 7 | }, 8 | rootDir: "./", 9 | verbose: true 10 | }; 11 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | output: "standalone", 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nehalist/hilde", 3 | "version": "0.3.4", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint && prettier --check src", 10 | "test": "jest" 11 | }, 12 | "homepage": "https://hilde.gg", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/nehalist/hilde" 16 | }, 17 | "author": "Kevin Hirczy (https://nehalist.io)", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@formkit/auto-animate": "^1.0.0-beta.3", 21 | "@hookform/resolvers": "^2.9.10", 22 | "@nivo/core": "^0.80.0", 23 | "@nivo/line": "^0.80.0", 24 | "@prisma/client": "^4.7.1", 25 | "@tanstack/react-query": "^4.15.1", 26 | "@trpc/client": "^10.5.0", 27 | "@trpc/next": "^10.5.0", 28 | "@trpc/react-query": "^10.5.0", 29 | "@trpc/server": "^10.5.0", 30 | "babel-plugin-superjson-next": "^0.4.4", 31 | "date-fns": "^2.29.3", 32 | "dotenv": "^16.0.3", 33 | "next": "^13.0.3", 34 | "next-auth": "^4.18.8", 35 | "next-seo": "^5.4.0", 36 | "next-themes": "^0.2.1", 37 | "prettier": "^2.6.2", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-hook-form": "^7.30.0", 41 | "react-icons": "^4.6.0", 42 | "react-toastify": "^9.1.1", 43 | "react-tooltip": "^4.5.0", 44 | "sqlite3": "^5.1.4", 45 | "superjson": "^1.12.0", 46 | "tsconfig-paths": "^4.1.0", 47 | "zod": "^3.19.1", 48 | "zustand": "^4.3.1" 49 | }, 50 | "devDependencies": { 51 | "@types/jest": "^29.2.5", 52 | "@types/node": "17.0.25", 53 | "@types/react": "18.0.25", 54 | "@types/react-dom": "18.0.8", 55 | "autoprefixer": "^10.4.4", 56 | "eslint": "8.13.0", 57 | "eslint-config-next": "^13.0.3", 58 | "eslint-config-prettier": "^8.5.0", 59 | "jest": "^29.3.1", 60 | "postcss": "^8.4.5", 61 | "prisma": "^4.7.1", 62 | "tailwindcss": "^3.2.4", 63 | "ts-jest": "^29.0.4", 64 | "ts-node": "^10.7.0", 65 | "typescript": "4.6.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20221221141310_create_tables/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `match` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 5 | `team1` VARCHAR(191) NOT NULL, 6 | `team2` VARCHAR(191) NOT NULL, 7 | `score1` INTEGER NOT NULL, 8 | `score2` INTEGER NOT NULL, 9 | `team1RatingChange` DOUBLE NOT NULL DEFAULT 0, 10 | `team2RatingChange` DOUBLE NOT NULL DEFAULT 0, 11 | `team1Rating` DOUBLE NOT NULL DEFAULT 0, 12 | `team2Rating` DOUBLE NOT NULL DEFAULT 0, 13 | `comment` VARCHAR(191) NOT NULL, 14 | `game` VARCHAR(191) NOT NULL, 15 | `teamsize` INTEGER NOT NULL, 16 | `season` INTEGER NOT NULL DEFAULT 1, 17 | 18 | PRIMARY KEY (`id`) 19 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 20 | 21 | -- CreateTable 22 | CREATE TABLE `team` ( 23 | `id` INTEGER NOT NULL AUTO_INCREMENT, 24 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 25 | `name` VARCHAR(191) NOT NULL, 26 | `teamsize` INTEGER NOT NULL, 27 | 28 | UNIQUE INDEX `team_name_key`(`name`), 29 | PRIMARY KEY (`id`) 30 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 31 | 32 | -- CreateTable 33 | CREATE TABLE `team_meta` ( 34 | `id` INTEGER NOT NULL AUTO_INCREMENT, 35 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 36 | `season` INTEGER NOT NULL DEFAULT 1, 37 | `teamId` INTEGER NOT NULL, 38 | `rating` DOUBLE NOT NULL DEFAULT 0, 39 | `achievementPoints` INTEGER NOT NULL DEFAULT 0, 40 | `totalMatches` INTEGER NOT NULL DEFAULT 0, 41 | `totalWins` INTEGER NOT NULL DEFAULT 0, 42 | `totalLosses` INTEGER NOT NULL DEFAULT 0, 43 | `totalWinRate` DOUBLE NOT NULL DEFAULT 0, 44 | `totalScore` INTEGER NOT NULL DEFAULT 0, 45 | `totalAvgScore` DOUBLE NOT NULL DEFAULT 0, 46 | `totalHighestRating` DOUBLE NOT NULL DEFAULT 0, 47 | `totalLowestRating` DOUBLE NOT NULL DEFAULT 0, 48 | `totalHighestWinStreak` INTEGER NOT NULL DEFAULT 0, 49 | `totalHighestLosingStreak` INTEGER NOT NULL DEFAULT 0, 50 | `dailyMatches` INTEGER NOT NULL DEFAULT 0, 51 | `dailyWins` INTEGER NOT NULL DEFAULT 0, 52 | `dailyLosses` INTEGER NOT NULL DEFAULT 0, 53 | `dailyWinRate` DOUBLE NOT NULL DEFAULT 0, 54 | `dailyScore` INTEGER NOT NULL DEFAULT 0, 55 | `dailyAvgScore` DOUBLE NOT NULL DEFAULT 0, 56 | `currentWinStreak` INTEGER NOT NULL DEFAULT 0, 57 | `currentLosingStreak` INTEGER NOT NULL DEFAULT 0, 58 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 59 | 60 | INDEX `team_meta_teamId_idx`(`teamId`), 61 | UNIQUE INDEX `team_meta_season_teamId_key`(`season`, `teamId`), 62 | PRIMARY KEY (`id`) 63 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 64 | 65 | -- CreateTable 66 | CREATE TABLE `team_achievement` ( 67 | `id` INTEGER NOT NULL AUTO_INCREMENT, 68 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 69 | `teamName` VARCHAR(191) NOT NULL, 70 | `teamId` INTEGER NOT NULL, 71 | `achievement` VARCHAR(191) NOT NULL, 72 | `season` INTEGER NOT NULL DEFAULT 1, 73 | `matchId` INTEGER NOT NULL, 74 | 75 | INDEX `team_achievement_matchId_teamId_idx`(`matchId`, `teamId`), 76 | PRIMARY KEY (`id`) 77 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 78 | -------------------------------------------------------------------------------- /prisma/migrations/20230114104715_add_season_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX `team_meta_teamId_idx` ON `team_meta`; 3 | 4 | -- CreateTable 5 | CREATE TABLE `season` ( 6 | `id` INTEGER NOT NULL AUTO_INCREMENT, 7 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 8 | `number` INTEGER NOT NULL, 9 | `current` BOOLEAN NOT NULL DEFAULT false, 10 | 11 | UNIQUE INDEX `season_number_key`(`number`), 12 | INDEX `season_number_idx`(`number`), 13 | PRIMARY KEY (`id`) 14 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 15 | 16 | -- CreateIndex 17 | CREATE INDEX `match_season_idx` ON `match`(`season`); 18 | 19 | -- CreateIndex 20 | CREATE INDEX `team_meta_teamId_season_idx` ON `team_meta`(`teamId`, `season`); 21 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model Match { 15 | id Int @id @default(autoincrement()) 16 | createdAt DateTime @default(now()) 17 | team1 String 18 | team2 String 19 | score1 Int 20 | score2 Int 21 | team1RatingChange Float @default(0) 22 | team2RatingChange Float @default(0) 23 | team1Rating Float @default(0) 24 | team2Rating Float @default(0) 25 | comment String 26 | game String 27 | teamsize Int 28 | season Int @default(1) 29 | achievements TeamAchievement[] 30 | 31 | @@index(season) 32 | @@map("match") 33 | } 34 | 35 | model Team { 36 | id Int @id @default(autoincrement()) 37 | createdAt DateTime @default(now()) 38 | name String 39 | teamsize Int 40 | meta TeamMeta[] 41 | achievements TeamAchievement[] 42 | 43 | @@unique(name) 44 | @@map("team") 45 | } 46 | 47 | model TeamMeta { 48 | id Int @id @default(autoincrement()) 49 | createdAt DateTime @default(now()) 50 | team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) 51 | season Int @default(1) 52 | teamId Int 53 | rating Float @default(0) 54 | achievementPoints Int @default(0) 55 | totalMatches Int @default(0) 56 | totalWins Int @default(0) 57 | totalLosses Int @default(0) 58 | totalWinRate Float @default(0) 59 | totalScore Int @default(0) 60 | totalAvgScore Float @default(0) 61 | totalHighestRating Float @default(0) 62 | totalLowestRating Float @default(0) 63 | totalHighestWinStreak Int @default(0) 64 | totalHighestLosingStreak Int @default(0) 65 | dailyMatches Int @default(0) 66 | dailyWins Int @default(0) 67 | dailyLosses Int @default(0) 68 | dailyWinRate Float @default(0) 69 | dailyScore Int @default(0) 70 | dailyAvgScore Float @default(0) 71 | currentWinStreak Int @default(0) 72 | currentLosingStreak Int @default(0) 73 | updatedAt DateTime @default(now()) 74 | 75 | @@unique([season, teamId]) 76 | @@index([teamId, season]) 77 | @@map("team_meta") 78 | } 79 | 80 | model TeamAchievement { 81 | id Int @id @default(autoincrement()) 82 | createdAt DateTime @default(now()) 83 | team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) 84 | teamName String 85 | teamId Int 86 | achievement String 87 | season Int @default(1) 88 | match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) 89 | matchId Int 90 | 91 | @@index([matchId, teamId]) 92 | @@map("team_achievement") 93 | } 94 | 95 | model Season { 96 | id Int @id @default(autoincrement()) 97 | createdAt DateTime @default(now()) 98 | number Int @unique 99 | current Boolean @default(false) 100 | 101 | @@index(number) 102 | @@map("season") 103 | } 104 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nehalist/hilde/8169f9acc8425faa03cc05f4d90f52074c5943e6/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Elements/AchievementList.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, FunctionComponent } from "react"; 2 | import { Achievement, achievements } from "~/utils/achievements"; 3 | import { TimeDistance } from "~/components/Elements/TeamDistance"; 4 | import { TeamWithMetaAndAchievements } from "~/server/model/team"; 5 | import { TiDeleteOutline } from "react-icons/ti"; 6 | import { trpc } from "~/utils/trpc"; 7 | import { toast } from "react-toastify"; 8 | import { useStore } from "~/utils/store"; 9 | 10 | const AchievementCard: FunctionComponent<{ 11 | achievement: Achievement; 12 | earned: Array<{ 13 | teamName: string; 14 | date: Date; 15 | cssClasses: string; 16 | id?: number; 17 | }>; 18 | display: "single" | "versus"; 19 | }> = ({ achievement, earned, display }) => { 20 | let bgColor; 21 | switch (true) { 22 | case achievement.points > 25: 23 | bgColor = "bg-gradient-to-b from-yellow-500 to-amber-600"; 24 | break; 25 | case achievement.points > 10: 26 | bgColor = "bg-gradient-to-b from-gray-400 to-gray-500"; 27 | break; 28 | default: 29 | bgColor = "bg-gradient-to-b from-amber-700 to-amber-800"; 30 | } 31 | const utils = trpc.useContext(); 32 | const deleteMutation = trpc.teams.deleteAchievement.useMutation({ 33 | onSuccess: async () => { 34 | await utils.teams.invalidate(); 35 | toast("Its gone. Forever.", { 36 | type: "success", 37 | }); 38 | }, 39 | }); 40 | 41 | return ( 42 |
43 |
44 |
45 | 48 | {achievement.points} 49 | 50 |
51 |
52 |
53 |

{achievement.title}

54 |
{achievement.description}
55 | 56 | {display === "single" ? ( 57 | <> 58 | ✅ 59 | 74 | 75 | ) : ( 76 | <> 77 | {earned.map(e => ( 78 | 79 | 82 | {e.teamName} 83 | 84 | 85 | ))} 86 | 87 | )} 88 | 89 |
90 |
91 | ); 92 | }; 93 | 94 | export const AchievementList: FunctionComponent<{ 95 | team: TeamWithMetaAndAchievements; 96 | versus?: TeamWithMetaAndAchievements; 97 | }> = ({ team, versus }) => { 98 | const selectedSeason = useStore(state => state.season); 99 | const teamAchievements = achievements.filter(achievement => 100 | team.achievements.find( 101 | a => a.achievement === achievement.id && a.season === selectedSeason, 102 | ), 103 | ); 104 | const versusAchievements = versus 105 | ? achievements.filter(achievement => 106 | versus.achievements.find( 107 | a => a.achievement === achievement.id && a.season === selectedSeason, 108 | ), 109 | ) 110 | : []; 111 | 112 | const uniqueAchievements = [ 113 | ...new Set([...teamAchievements, ...versusAchievements]), 114 | ]; 115 | 116 | return ( 117 |
118 | {uniqueAchievements.map(achievement => ( 119 | a.id === achievement.id) 125 | .map(() => { 126 | const teamAchievement = team.achievements.find( 127 | a => a.achievement === achievement.id, 128 | )!; 129 | return { 130 | id: teamAchievement.id, 131 | teamName: team.name, 132 | date: teamAchievement.createdAt, 133 | cssClasses: "bg-lime-600 text-white", 134 | }; 135 | }), 136 | ...versusAchievements 137 | .filter(a => a.id === achievement.id) 138 | .map(() => ({ 139 | teamName: versus!.name, 140 | date: versus!.achievements.find( 141 | a => a.achievement === achievement.id, 142 | )!.createdAt, 143 | cssClasses: "bg-cyan-600 text-white", 144 | })), 145 | ]} 146 | display={versus ? "versus" : "single"} 147 | /> 148 | ))} 149 |
150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/Elements/Card.tsx: -------------------------------------------------------------------------------- 1 | export const Card = ({ children }) => ( 2 |
3 | {children} 4 |
5 | ); 6 | -------------------------------------------------------------------------------- /src/components/Elements/EloHistory.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionComponent } from "react"; 2 | import { useMemo } from "react"; 3 | import { ResponsiveLine, Serie } from "@nivo/line"; 4 | import { Match } from "@prisma/client"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export const EloHistory: FunctionComponent<{ 8 | matches: Match[]; 9 | teams: string[]; 10 | }> = ({ teams, matches }) => { 11 | const data: Serie[] = useMemo( 12 | () => 13 | teams.map(t => ({ 14 | id: t, 15 | data: matches 16 | .sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1)) 17 | .map(m => ({ 18 | x: m.id, 19 | y: m.team1 === t ? m.team1Rating : m.team2Rating, 20 | })), 21 | })), 22 | [teams, matches], 23 | ); 24 | const { theme } = useTheme(); 25 | 26 | return ( 27 |
28 | { 36 | return ( 37 |
38 | {point.serieId}: {(point.data.y as number).toFixed(2)} 39 |
40 | ); 41 | }} 42 | // TODO: animating charts leads to a bug where the line becomes buggy when versus is set 43 | animate={false} 44 | colors={["#65a30d", "#0891b2"]} // equals lime-600 and cyan-600 45 | theme={ 46 | theme === "dark" 47 | ? { 48 | axis: { 49 | ticks: { 50 | text: { 51 | fill: "#9ca3af", 52 | }, 53 | }, 54 | }, 55 | grid: { 56 | line: { 57 | stroke: "#4b5563", 58 | strokeWidth: 1, 59 | }, 60 | }, 61 | } 62 | : {} 63 | } 64 | curve="linear" 65 | axisTop={null} 66 | axisRight={null} 67 | enablePoints={matches.length < 100} 68 | axisBottom={null} 69 | useMesh={true} 70 | enableGridX={matches.length < 100} 71 | /> 72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/Elements/LeaderboardsTable.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, useCallback } from "react"; 2 | import { Leaderboards } from "~/model"; 3 | import { Card } from "~/components/Elements"; 4 | 5 | const PlaceIcon: FunctionComponent<{ place: number }> = ({ place }) => { 6 | switch (place) { 7 | case 1: 8 | return 🥇; 9 | case 2: 10 | return 🥈; 11 | case 3: 12 | return 🥉; 13 | } 14 | return null; 15 | }; 16 | 17 | const LeaderboardsRow: FunctionComponent<{ 18 | place: number; 19 | leaderboards: Leaderboards; 20 | }> = ({ leaderboards, place }) => { 21 | const col = useCallback( 22 | (category: string, valueFormat?: (value) => ReactElement) => { 23 | const entry = leaderboards.places.find( 24 | p => p.place === place && p.category === category, 25 | ); 26 | if (!entry) { 27 | return null; 28 | } 29 | return ( 30 | 31 |
{entry.team}
32 |
33 | {valueFormat ? valueFormat(entry.value) : entry.value} 34 |
35 | 36 | ); 37 | }, 38 | [leaderboards, place], 39 | ); 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | {col("wins")} 47 | {col("score")} 48 | {col("matches")} 49 | {col("winRate", v => ( 50 | <>{(v * 100).toFixed(2)}% 51 | ))} 52 | {col("rating", v => ( 53 | <>{v.toFixed(2)} 54 | ))} 55 | 56 | ); 57 | }; 58 | 59 | export const LeaderboardsTable: FunctionComponent<{ 60 | leaderboards: Leaderboards; 61 | }> = ({ leaderboards }) => { 62 | if (leaderboards.totalMatches.total === 0) { 63 | return
No matches played yet
; 64 | } 65 | 66 | return ( 67 | 68 |
69 |
70 | Total Matches: {leaderboards.totalMatches.total} 71 |
72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | {[0, 1, 2].map(place => ( 87 | 92 | ))} 93 | 94 |
WinsScoreMatchesWinrateRating
95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/Elements/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | export const LoadingIndicator = () => ( 2 | 9 | 13 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/Elements/MatchTable.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useEffect } from "react"; 2 | import { TiDeleteOutline } from "react-icons/ti"; 3 | import { toast } from "react-toastify"; 4 | import { trpc } from "~/utils/trpc"; 5 | import { RatingChange, Score, TimeDistance } from "~/components/Elements"; 6 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 7 | import dynamic from "next/dynamic"; 8 | import { BiCommentDetail } from "react-icons/bi"; 9 | import { TeamLink } from "~/components/Elements/TeamLink"; 10 | import { MatchWithAchievements } from "~/server/model/match"; 11 | import { achievements } from "~/utils/achievements"; 12 | 13 | const ReactTooltip = dynamic(() => import("react-tooltip"), { ssr: false }); 14 | 15 | export const MatchTable: FunctionComponent<{ 16 | matches: MatchWithAchievements[]; 17 | animated?: boolean; 18 | }> = ({ matches, animated = true }) => { 19 | const [parent, enable] = useAutoAnimate(); 20 | useEffect(() => enable(animated), [animated, enable]); 21 | const utils = trpc.useContext(); 22 | const deleteMutation = trpc.matches.delete.useMutation({ 23 | onSuccess: async () => { 24 | await utils.matches.invalidate(); 25 | toast("Its gone. Forever.", { 26 | type: "success", 27 | }); 28 | }, 29 | }); 30 | 31 | if (matches.length === 0) { 32 | return null; 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {matches.map((match, index) => ( 47 | 53 | 81 | 109 | 127 | 144 | 145 | ))} 146 | 147 |
Team 1Team 2ResultDate
54 | {" "} 55 | {match.score1 > match.score2 ? "🏆" : ""} 56 | {match.achievements.filter(a => a.teamName === match.team1) 57 | .length > 0 && ( 58 | <> 59 | 62 | match.achievements 63 | .filter(a => a.teamName === match.team1) 64 | .find(ac => a.id === ac.achievement), 65 | ) 66 | .map(a => a.title) 67 | .join(", ")} 68 | data-for={`match-achievements-team1-${match.id}`} 69 | > 70 | 🏅 71 | 72 | 76 | 77 | )}{" "} 78 | {match.score1 === 0 ? "✂️" : ""}{" "} 79 | 80 | 82 | {" "} 83 | {match.score2 > match.score1 ? "🏆" : ""}{" "} 84 | {match.achievements.filter(a => a.teamName === match.team2) 85 | .length > 0 && ( 86 | <> 87 | 90 | match.achievements 91 | .filter(a => a.teamName === match.team2) 92 | .find(ac => a.id === ac.achievement), 93 | ) 94 | .map(a => a.title) 95 | .join(", ")} 96 | data-for={`match-achievements-team2-${match.id}`} 97 | > 98 | 🏅 99 | 100 | 104 | 105 | )}{" "} 106 | {match.score2 === 0 ? "✂️" : ""}{" "} 107 | 108 | 110 | : 111 | {match.comment !== "" && ( 112 | <> 113 | 120 | 124 | 125 | )} 126 | 128 | 129 | 143 |
148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /src/components/Elements/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useMemo } from "react"; 2 | import { Card } from "~/components/Elements/Card"; 3 | import { EloHistory } from "~/components/Elements/EloHistory"; 4 | import { Match, Team, TeamMeta } from "@prisma/client"; 5 | import { TeamWithMetaAndAchievements } from "~/server/model/team"; 6 | import { AchievementList } from "~/components/Elements/AchievementList"; 7 | import { getSeasonMeta } from "~/model"; 8 | import { useStore } from "~/utils/store"; 9 | 10 | export const Profile: FunctionComponent<{ 11 | team: TeamWithMetaAndAchievements; 12 | versus?: TeamWithMetaAndAchievements | null; 13 | matches?: Match[]; 14 | onVersusSelect: (teamName: string) => void; 15 | versusOptions: Team[]; 16 | }> = ({ team, versus, matches, onVersusSelect, versusOptions }) => { 17 | const selectedSeason = useStore(state => state.season); 18 | const meta = getSeasonMeta(team, selectedSeason); 19 | const vsMeta = versus ? getSeasonMeta(versus, selectedSeason) : undefined; 20 | 21 | const versusStats = useMemo((): Partial | null => { 22 | if (!matches || !versus) { 23 | return null; 24 | } 25 | const playedMatches = matches.filter( 26 | m => m.team1 === versus.name || m.team2 === versus.name, 27 | ); 28 | const wins = matches.filter( 29 | m => 30 | (m.team1 === team.name && m.score1 > m.score2) || 31 | (m.team2 === team.name && m.score2 > m.score1), 32 | ).length; 33 | const score = playedMatches.reduce((acc, m) => { 34 | return acc + (m.team1 === team.name ? m.score1 : m.score2); 35 | }, 0); 36 | return { 37 | totalMatches: playedMatches.length, 38 | totalWins: wins, 39 | totalWinRate: 40 | playedMatches.length === 0 ? 0 : wins / playedMatches.length, 41 | totalLosses: playedMatches.length - wins, 42 | totalScore: score, 43 | totalAvgScore: score / playedMatches.length, 44 | }; 45 | }, [team, versus, matches]); 46 | 47 | const stat = (stat: keyof TeamMeta, float = false) => { 48 | let value = versus && versusStats ? versusStats[stat] : meta[stat]; 49 | if (!value) { 50 | return 0; 51 | } 52 | if (stat === "totalWinRate") { 53 | value *= 100; 54 | } 55 | if (float) { 56 | value = +value.toFixed(2); 57 | } 58 | if (versus) { 59 | return {value}; 60 | } 61 | return value; 62 | }; 63 | 64 | if (!meta) { 65 | return null; 66 | } 67 | 68 | return ( 69 | 70 |
71 |
72 |

73 | {team.name} 74 |

75 |
76 | {versus && "vs."} 77 | 91 |
92 |
93 |
94 |
95 |
96 |

97 | {meta.rating.toFixed(2)} 98 | 99 | {vsMeta?.rating.toFixed(2)} 100 | 101 |

102 |

Current Rating

103 |
104 |
105 |

106 | {meta.totalHighestRating.toFixed(2)} 107 | 108 | {vsMeta?.totalHighestRating.toFixed(2)} 109 | 110 |

111 |

Highest Rating

112 |
113 |
114 |

115 | {meta.totalLowestRating.toFixed(2)} 116 | 117 | {vsMeta?.totalLowestRating.toFixed(2)} 118 | 119 |

120 |

Lowest Rating

121 |
122 |
123 |
124 |

{stat("totalMatches")}

125 |

Matches

126 |
127 |
128 |

{stat("totalWins")}

129 |

Wins

130 |
131 |
132 |

{stat("totalLosses")}

133 |

Losses

134 |
135 |
136 |

{stat("totalScore")}

137 |

Total score

138 |
139 |
140 |

{stat("totalAvgScore", true)}

141 |

Average score

142 |
143 |
144 |

{stat("totalWinRate", true)}%

145 |

Winrate

146 |
147 |
148 |
149 |

150 | {meta.currentWinStreak} 151 | 152 | {vsMeta?.currentWinStreak} 153 | 154 |

155 |

Current winstreak

156 |
157 |
158 |

159 | {meta.totalHighestWinStreak} 160 | 161 | {vsMeta?.totalHighestWinStreak} 162 | 163 |

164 |

Highest winstreak

165 |
166 |
167 |

168 | {meta.totalHighestLosingStreak} 169 | 170 | {vsMeta?.totalHighestLosingStreak} 171 | 172 |

173 |

Highest losing streak

174 |
175 |
176 |
177 |
178 | {matches && matches.length > 0 ? ( 179 | 183 | ) : ( 184 |
185 | No matches found 186 |
187 | )} 188 |
189 |
190 |
191 | 🏅 {meta.achievementPoints} 192 | 193 | {vsMeta?.achievementPoints} 194 | 195 |
196 | 197 |
198 |
199 | 200 | ); 201 | }; 202 | -------------------------------------------------------------------------------- /src/components/Elements/RatingChange.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | 3 | export const RatingChange: FunctionComponent<{ rating: number }> = ({ 4 | rating, 5 | }) => { 6 | if (rating > 0) { 7 | return +{rating.toFixed(2)}; 8 | } else if (rating < 0) { 9 | return {rating.toFixed(2)}; 10 | } 11 | return null; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Elements/Score.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | 3 | export const Score: FunctionComponent<{ score: number }> = ({ score }) => ( 4 | 5 | {score} 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/Elements/SeasonList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import { Season } from "@prisma/client"; 3 | import { TiDeleteOutline, TiMediaPlayOutline } from "react-icons/ti"; 4 | import { TimeDistance } from "~/components/Elements/TeamDistance"; 5 | import { trpc } from "~/utils/trpc"; 6 | import { toast } from "react-toastify"; 7 | 8 | export const SeasonList: FunctionComponent<{ seasons: Season[] }> = ({ 9 | seasons, 10 | }) => { 11 | const utils = trpc.useContext(); 12 | const activateMutation = trpc.seasons.activate.useMutation({ 13 | onSuccess: () => utils.seasons.invalidate(), 14 | }); 15 | const deleteMutation = trpc.seasons.delete.useMutation({ 16 | onSuccess: () => { 17 | toast("Its gone. Forever.", { 18 | type: "success", 19 | }); 20 | utils.seasons.invalidate(); 21 | }, 22 | }); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | {seasons.map((season, index) => ( 35 | 41 | 52 | 88 | 89 | ))} 90 | 91 |
Name 31 |
42 | {season.number} 43 | {season.current && ( 44 | 45 | Active 46 | 47 | )} 48 |
49 | 50 |
51 |
53 |
54 | {!season.current && ( 55 | <> 56 | 70 | 84 | 85 | )} 86 |
87 |
92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/Elements/SeasonSelector.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useEffect } from "react"; 2 | import { trpc } from "~/utils/trpc"; 3 | import { useStore } from "~/utils/store"; 4 | 5 | export const SeasonSelector: FunctionComponent = () => { 6 | const season = trpc.seasons.list.useQuery(undefined, { 7 | refetchOnWindowFocus: false, 8 | }); 9 | const { season: selectedSeason, setSeason } = useStore(); 10 | 11 | useEffect(() => { 12 | console.log(season.data); 13 | if (!season.data) { 14 | return; 15 | } 16 | setSeason(season.data.find(s => s.current)?.number || 1); 17 | }, [season.data, setSeason]); 18 | 19 | if (season.isLoading || !season.data) { 20 | return null; 21 | } 22 | 23 | return ( 24 | 25 | Season{" "} 26 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/Elements/TeamDistance.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useEffect, useState } from "react"; 2 | import { differenceInHours, format, formatDistance } from "date-fns"; 3 | 4 | const threshold = 24; 5 | 6 | export const TimeDistance: FunctionComponent<{ date: Date }> = ({ date }) => { 7 | const [baseDate, setBaseDate] = useState(new Date()); 8 | const distance = differenceInHours(date, baseDate) * -1; 9 | const formattedDate = format(date, "dd.LL.yyyy HH:mm"); 10 | 11 | useEffect(() => { 12 | if (distance >= threshold) { 13 | return; 14 | } 15 | const interval = setInterval(() => setBaseDate(new Date()), 60000); 16 | return () => clearInterval(interval); 17 | }); 18 | 19 | if (distance >= threshold) { 20 | return ; 21 | } 22 | 23 | return ( 24 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/Elements/TeamLink.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import { Team } from "@prisma/client"; 3 | import Link from "next/link"; 4 | 5 | export const TeamLink: FunctionComponent<{ team: Team | string }> = ({ 6 | team, 7 | }) => { 8 | if (typeof team === "object") { 9 | team = team.name; 10 | } 11 | return ( 12 | 13 | {team} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/Elements/TeamTable.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useCallback, useMemo, useState } from "react"; 2 | import { TiDeleteOutline } from "react-icons/ti"; 3 | import { useQueryClient } from "@tanstack/react-query"; 4 | import { toast } from "react-toastify"; 5 | import { trpc } from "~/utils/trpc"; 6 | import { getSeasonMeta } from "~/model"; 7 | import { TeamLink } from "~/components/Elements/TeamLink"; 8 | import { TeamWithMeta } from "~/server/model/team"; 9 | import { useStore } from "~/utils/store"; 10 | 11 | type TeamOrder = 12 | | "name" 13 | | "matches" 14 | | "wins" 15 | | "score" 16 | | "losses" 17 | | "winRate" 18 | | "achievementPoints" 19 | | "rating"; 20 | 21 | export const TeamTable: FunctionComponent<{ teams: TeamWithMeta[] }> = ({ 22 | teams, 23 | }) => { 24 | const selectedSeason = useStore(state => state.season); 25 | const [orderBy, setOrderBy] = useState("rating"); 26 | const [order, setOrder] = useState<"asc" | "desc">("asc"); 27 | const queryClient = useQueryClient(); 28 | const mutation = trpc.teams.delete.useMutation({ 29 | onSuccess: async () => { 30 | await queryClient.invalidateQueries(); 31 | toast("Its gone. Forever.", { 32 | type: "success", 33 | }); 34 | }, 35 | onError: () => { 36 | toast("Failed to delete team.", { 37 | type: "error", 38 | }); 39 | }, 40 | }); 41 | 42 | const orderedTeams = useMemo(() => { 43 | const ordered = teams.sort((team1, team2) => { 44 | const meta1 = getSeasonMeta(team1, selectedSeason); 45 | const meta2 = getSeasonMeta(team2, selectedSeason); 46 | 47 | switch (orderBy) { 48 | case "name": 49 | return team1.name.localeCompare(team2.name); 50 | case "matches": 51 | return meta1.totalMatches - meta2.totalMatches; 52 | case "wins": 53 | return meta1.totalWins - meta2.totalWins; 54 | case "losses": 55 | return meta2.totalLosses - meta1.totalLosses; 56 | case "score": 57 | return meta2.totalScore - meta1.totalScore; 58 | case "winRate": 59 | return meta2.totalWinRate - meta1.totalWinRate; 60 | case "achievementPoints": 61 | return meta2.achievementPoints - meta1.achievementPoints; 62 | default: 63 | case "rating": 64 | return meta2.rating - meta1.rating; 65 | } 66 | }); 67 | 68 | if (order === "desc") { 69 | ordered.reverse(); 70 | } 71 | 72 | return ordered; 73 | }, [teams, order, selectedSeason, orderBy]); 74 | 75 | const setOrdering = (orderBy: TeamOrder) => { 76 | setOrderBy(orderBy); 77 | setOrder(order === "asc" ? "desc" : "asc"); 78 | }; 79 | 80 | const orderIcon = useCallback( 81 | header => { 82 | if (header !== orderBy) { 83 | return null; 84 | } 85 | return order === "asc" ? "▲" : "▼"; 86 | }, 87 | [order, orderBy], 88 | ); 89 | 90 | return ( 91 | 92 | 93 | 94 | 97 | 100 | 103 | 106 | 109 | 112 | 115 | 118 | 119 | 120 | 121 | {orderedTeams 122 | .map(t => ({ ...t, meta: getSeasonMeta(t, selectedSeason) })) 123 | .map((team, index) => ( 124 | 130 | 147 | 148 | 149 | 150 | 158 | 163 | 164 | 165 | 166 | ))} 167 | 168 |
setOrdering("name")}> 95 | Name {orderIcon("name")} 96 | setOrdering("matches")}> 98 | Matches {orderIcon("matches")} 99 | setOrdering("wins")}> 101 | Wins {orderIcon("wins")} 102 | setOrdering("losses")}> 104 | Losses {orderIcon("losses")} 105 | setOrdering("score")}> 107 | Score {orderIcon("score")} 108 | setOrdering("winRate")}> 110 | Winrate {orderIcon("winRate")} 111 | setOrdering("achievementPoints")}> 113 | Ach. {orderIcon("achievementPoints")} 114 | setOrdering("rating")}> 116 | Rating {orderIcon("rating")} 117 |
131 | 132 | 146 | {team.meta.totalMatches}{team.meta.totalWins}{team.meta.totalLosses} 151 | {team.meta.totalScore}{" "} 152 | {team.meta.totalMatches > 0 && ( 153 | 154 | Ø {team.meta.totalAvgScore.toFixed(2)} 155 | 156 | )} 157 | 159 | {team.meta.totalMatches > 0 && ( 160 | <>{(team.meta.totalWinRate * 100).toFixed(2)}% 161 | )} 162 | {+team.meta.achievementPoints}{+team.meta.rating.toFixed(2)}
169 | ); 170 | }; 171 | -------------------------------------------------------------------------------- /src/components/Elements/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Card"; 2 | export * from "./LoadingIndicator"; 3 | export * from "./TeamTable"; 4 | export * from "./MatchTable"; 5 | export * from "./RatingChange"; 6 | export * from "./TeamDistance"; 7 | export * from "./Score"; 8 | export * from "./AchievementList"; 9 | export * from "./LeaderboardsTable"; 10 | export * from "./SeasonSelector"; 11 | export * from "./SeasonList"; 12 | -------------------------------------------------------------------------------- /src/components/Form/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, HTMLAttributes } from "react"; 2 | 3 | interface InputProps extends HTMLAttributes { 4 | type?: string; 5 | label: string; 6 | placeholder: string; 7 | } 8 | 9 | export const Input = forwardRef( 10 | function InputComponent( 11 | { type, label, placeholder, ...props }: InputProps, 12 | ref, 13 | ) { 14 | return ( 15 | <> 16 | 19 | 26 | 27 | ); 28 | }, 29 | ); 30 | -------------------------------------------------------------------------------- /src/components/Form/MatchCreationForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { zodResolver } from "@hookform/resolvers/zod"; 3 | import { trpc } from "~/utils/trpc"; 4 | import { toast } from "react-toastify"; 5 | import { Card, LoadingIndicator } from "~/components/Elements"; 6 | import { Input } from "~/components/Form"; 7 | import { matchAddValidation } from "~/utils/validation"; 8 | import { useStore } from "~/utils/store"; 9 | 10 | interface FormValues { 11 | team1: string; 12 | team2: string; 13 | score1: number | string; 14 | score2: number | string; 15 | comment: string; 16 | } 17 | 18 | export const MatchCreationForm = () => { 19 | const { 20 | handleSubmit, 21 | reset, 22 | register, 23 | formState: { isValid }, 24 | setFocus, 25 | } = useForm({ 26 | defaultValues: { 27 | team1: "", 28 | team2: "", 29 | score1: "", 30 | score2: "", 31 | comment: "", 32 | }, 33 | resolver: zodResolver(matchAddValidation), 34 | mode: "all", 35 | }); 36 | 37 | const selectedSeason = useStore(state => +state.season); 38 | const seasons = trpc.seasons.list.useQuery(undefined, { 39 | refetchOnWindowFocus: false, 40 | }); 41 | const utils = trpc.useContext(); 42 | const mutation = trpc.matches.add.useMutation({ 43 | onSuccess: async () => { 44 | await utils.matches.invalidate(); 45 | toast("Match saved.", { 46 | type: "success", 47 | }); 48 | setFocus("team1"); 49 | reset(); 50 | }, 51 | onError: err => { 52 | toast(`Error: ${err.message}`, { 53 | type: "error", 54 | }); 55 | }, 56 | }); 57 | 58 | if (seasons.isLoading) { 59 | return ( 60 |
61 | 62 |
63 | ); 64 | } 65 | 66 | return ( 67 |
69 | mutation.mutate({ 70 | ...values, 71 | score1: +values.score1, 72 | score2: +values.score2, 73 | }), 74 | )} 75 | > 76 | 77 |
78 |
79 |
80 |
81 |
82 | 87 |
88 |
89 | 95 |
96 |
97 |
98 |
99 |

vs

100 |
101 |
102 |
103 |
104 | 109 |
110 |
111 | 117 |
118 |
119 |
120 |
121 |
122 |
123 | 129 | {seasons.data?.find(s => s.current)?.number === selectedSeason ? ( 130 | 137 | ) : ( 138 | Season is over! 139 | )} 140 |
141 |
142 |
143 | ); 144 | }; 145 | -------------------------------------------------------------------------------- /src/components/Form/Select.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler, FunctionComponent } from "react"; 2 | 3 | export const Select: FunctionComponent<{ 4 | label: string; 5 | placeholder?: string; 6 | options: Array<{ label: string; value: string }>; 7 | selectedValue: string; 8 | onChange: ChangeEventHandler; 9 | }> = ({ label, placeholder, selectedValue, onChange, options }) => ( 10 | <> 11 | 14 | 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /src/components/Form/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Input"; 2 | export * from "./Select"; 3 | export * from "./MatchCreationForm"; 4 | -------------------------------------------------------------------------------- /src/components/Layout/AdminLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { signOut } from "next-auth/react"; 3 | 4 | export const AdminLayout = ({ children }: { children: ReactNode }) => ( 5 |
6 | 18 |
{children}
19 |
20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FunctionComponent, ReactNode } from "react"; 3 | import { useRouter } from "next/router"; 4 | import packageJson from "../../../package.json"; 5 | import { MdDarkMode, MdLightMode } from "react-icons/md"; 6 | import { useTheme } from "next-themes"; 7 | import { SeasonSelector } from "~/components/Elements"; 8 | 9 | const NavLink: FunctionComponent<{ label: string; href: string }> = ({ 10 | label, 11 | href, 12 | }) => { 13 | const router = useRouter(); 14 | return ( 15 | 21 | {label} 22 | 23 | ); 24 | }; 25 | 26 | export const Layout: FunctionComponent<{ children: ReactNode }> = ({ 27 | children, 28 | }) => { 29 | const { theme, setTheme } = useTheme(); 30 | 31 | return ( 32 |
33 |
34 |
35 |
36 | 37 |

47 | Hilde 48 |

49 | 50 | 51 |
52 |
53 |
    54 |
  • 55 | 56 |
  • 57 |
  • 58 | 59 |
  • 60 |
  • 61 | 62 |
  • 63 |
64 |
65 |
66 | {children} 67 |
68 |
69 |
70 | 82 |
83 |
84 | Hilde v{packageJson.version} - by{" "} 85 | 86 | nehalist.io 87 | 88 |
89 |
90 |
91 | ); 92 | }; 93 | 94 | export default Layout; 95 | -------------------------------------------------------------------------------- /src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Layout"; 2 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from "next-auth/middleware"; 2 | 3 | export const config = { 4 | matcher: ["/admin/:path*"], 5 | }; 6 | -------------------------------------------------------------------------------- /src/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./leaderboards"; 2 | export * from "./team"; 3 | -------------------------------------------------------------------------------- /src/model/leaderboards.ts: -------------------------------------------------------------------------------- 1 | export interface Leaderboards { 2 | season: number; 3 | totalMatches: { 4 | total: number; 5 | perDay: number; 6 | days: number; 7 | }; 8 | places: Array<{ 9 | category: "wins" | "score" | "matches" | "winRate" | "rating"; 10 | team: string; 11 | value: number; 12 | place: number; 13 | }>; 14 | } 15 | -------------------------------------------------------------------------------- /src/model/team.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Keep in mind that there shouldn't be any server-related logic in this file, so it 3 | * can be imported client-side as well. 4 | */ 5 | import { defaultRating } from "~/utils/elo"; 6 | import { TeamWithMeta } from "~/server/model/team"; 7 | 8 | export function getDefaultTeamMeta(season: number) { 9 | return { 10 | id: 0, 11 | createdAt: new Date(), 12 | updatedAt: new Date(), 13 | season: season, 14 | rating: defaultRating, 15 | achievementPoints: 0, 16 | totalMatches: 0, 17 | totalWins: 0, 18 | totalLosses: 0, 19 | totalScore: 0, 20 | totalAvgScore: 0, 21 | totalWinRate: 0, 22 | totalHighestRating: defaultRating, 23 | totalLowestRating: defaultRating, 24 | totalHighestWinStreak: 0, 25 | totalHighestLosingStreak: 0, 26 | dailyMatches: 0, 27 | dailyWins: 0, 28 | dailyLosses: 0, 29 | dailyScore: 0, 30 | dailyAvgScore: 0, 31 | dailyWinRate: 0, 32 | currentLosingStreak: 0, 33 | currentWinStreak: 0, 34 | }; 35 | } 36 | 37 | export function getSeasonMeta(team: TeamWithMeta, season: number) { 38 | return team.meta.find(m => m.season === season) || getDefaultTeamMeta(season); 39 | } 40 | 41 | export function getTeamSize(teamName: string) { 42 | return teamName.split(",").length; 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { ToastContainer } from "react-toastify"; 3 | import "../styles/globals.css"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | import { DefaultSeo } from "next-seo"; 6 | import { trpc } from "~/utils/trpc"; 7 | import { Layout } from "~/components/Layout"; 8 | import { ThemeProvider } from "next-themes"; 9 | import { SessionProvider } from "next-auth/react"; 10 | 11 | function App({ Component, pageProps }: AppProps) { 12 | return ( 13 | 14 | 15 | 16 | 21 | 22 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default trpc.withTRPC(App); 37 | -------------------------------------------------------------------------------- /src/pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { AdminLayout } from "~/components/Layout/AdminLayout"; 2 | import { GetServerSideProps } from "next"; 3 | 4 | const Admin = () => { 5 | return Hello there; 6 | }; 7 | 8 | export default Admin; 9 | 10 | export const getServerSideProps: GetServerSideProps = async () => { 11 | return { 12 | redirect: { 13 | destination: "/admin/seasons", 14 | permanent: false, 15 | }, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/pages/admin/seasons.tsx: -------------------------------------------------------------------------------- 1 | import { AdminLayout } from "~/components/Layout/AdminLayout"; 2 | import { SeasonList } from "~/components/Elements"; 3 | import { trpc } from "~/utils/trpc"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { Input } from "~/components/Form"; 7 | import { seasonAddValidation } from "~/utils/validation"; 8 | import { toast } from "react-toastify"; 9 | 10 | const Seasons = () => { 11 | const seasons = trpc.seasons.list.useQuery(); 12 | const utils = trpc.useContext(); 13 | const { 14 | register, 15 | handleSubmit, 16 | formState: { isValid }, 17 | reset, 18 | } = useForm<{ number: string }>({ 19 | defaultValues: { 20 | number: "", 21 | }, 22 | resolver: zodResolver(seasonAddValidation), 23 | mode: "all", 24 | }); 25 | const mutation = trpc.seasons.add.useMutation({ 26 | onSuccess: () => { 27 | toast("Season added. Don't forget to activate it!", { 28 | type: "success", 29 | }); 30 | utils.seasons.invalidate(); 31 | reset(); 32 | }, 33 | }); 34 | 35 | return ( 36 | 37 |

Seasons

38 | 39 |
41 | mutation.mutate({ number: +values.number }), 42 | )} 43 | > 44 |
45 |
46 | 52 |
53 |
54 | 61 |
62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default Seasons; 69 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { AuthOptions } from "next-auth"; 2 | import CredentialsProvider from "next-auth/providers/credentials"; 3 | 4 | export const authOptions: AuthOptions = { 5 | providers: [ 6 | CredentialsProvider({ 7 | name: "Credentials", 8 | credentials: { 9 | password: { label: "Password", type: "password" }, 10 | }, 11 | async authorize(credentials) { 12 | if (credentials?.password === process.env.ADMIN_PASSWORD) { 13 | return { id: "1", name: "admin" }; 14 | } 15 | return null; 16 | }, 17 | }), 18 | ], 19 | }; 20 | export default NextAuth(authOptions); 21 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from "@trpc/server/adapters/next"; 2 | import { appRouter } from "~/server/routers/_app"; 3 | import { createContext } from "~/server/context"; 4 | 5 | export default trpcNext.createNextApiHandler({ 6 | router: appRouter, 7 | createContext, 8 | onError({ error }) { 9 | if (error.code === "INTERNAL_SERVER_ERROR") { 10 | // send to bug reporting 11 | console.error("Something went wrong", error); 12 | } 13 | }, 14 | batching: { 15 | enabled: true, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from "next-seo"; 2 | import { trpc } from "~/utils/trpc"; 3 | import { MatchTable } from "~/components/Elements"; 4 | import { MatchCreationForm } from "~/components/Form"; 5 | import { useStore } from "~/utils/store"; 6 | 7 | const Home = () => { 8 | const selectedSeason = useStore(state => +state.season); 9 | const matches = trpc.matches.list.useQuery({ 10 | limit: 5, 11 | season: selectedSeason, 12 | }); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | {matches.data && matches.data.length > 0 && ( 19 |
20 |
21 |

22 | Recent Matches 23 |

24 |
25 | 26 |
27 | )} 28 | 29 | ); 30 | }; 31 | 32 | export default Home; 33 | -------------------------------------------------------------------------------- /src/pages/leaderboards.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from "next-seo"; 2 | import { LeaderboardsTable, LoadingIndicator } from "~/components/Elements"; 3 | import { trpc } from "~/utils/trpc"; 4 | import { useStore } from "~/utils/store"; 5 | 6 | const Leaderboards = () => { 7 | const selectedSeason = useStore(state => +state.season); 8 | const { data, isLoading } = trpc.leaderboards.forSeason.useQuery({ 9 | season: selectedSeason, 10 | }); 11 | 12 | return ( 13 | <> 14 | 15 | {isLoading || !data ? ( 16 |
17 | 18 |
19 | ) : ( 20 | 21 | )} 22 | 23 | ); 24 | }; 25 | 26 | export default Leaderboards; 27 | -------------------------------------------------------------------------------- /src/pages/matches.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from "next-seo"; 2 | import { trpc } from "~/utils/trpc"; 3 | import { Card, MatchTable } from "~/components/Elements"; 4 | import { useState } from "react"; 5 | import { Select } from "~/components/Form"; 6 | import { useStore } from "~/utils/store"; 7 | 8 | const Matches = () => { 9 | const [team1Filter, setTeam1Filter] = useState(""); 10 | const [team2Filter, setTeam2Filter] = useState(""); 11 | const [team, setTeam] = useState(true); 12 | 13 | const selectedSeason = useStore(state => +state.season); 14 | const { 15 | data: matches, 16 | hasNextPage, 17 | fetchNextPage, 18 | } = trpc.matches.infiniteList.useInfiniteQuery( 19 | { 20 | limit: 25, 21 | team1: team1Filter, 22 | team2: team2Filter, 23 | exact: !team, 24 | season: selectedSeason, 25 | }, 26 | { 27 | getNextPageParam: lastPage => lastPage.nextCursor, 28 | keepPreviousData: true, 29 | }, 30 | ); 31 | const { data: teams } = trpc.teams.list.useQuery({}); 32 | 33 | return ( 34 | <> 35 | 36 |
37 |
38 |
39 | ({ label: t.name, value: t.name })) 59 | .filter(t => t.value !== team1Filter), 60 | ]} 61 | selectedValue={team2Filter} 62 | onChange={e => setTeam2Filter(e.target.value)} 63 | /> 64 |
65 | )} 66 | {team1Filter && ( 67 | 83 | )} 84 |
85 |
86 | 87 | i.items) 92 | .flat() || [] 93 | } 94 | animated={false} 95 | /> 96 | {hasNextPage && ( 97 |
98 | 104 |
105 | )} 106 |
107 | 108 | ); 109 | }; 110 | 111 | export default Matches; 112 | -------------------------------------------------------------------------------- /src/pages/teams/[name].tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from "next-seo"; 2 | import { trpc } from "~/utils/trpc"; 3 | import { useRouter } from "next/router"; 4 | import { LoadingIndicator } from "~/components/Elements"; 5 | import { useState } from "react"; 6 | import { Profile } from "~/components/Elements/Profile"; 7 | import { useStore } from "~/utils/store"; 8 | 9 | const Team = () => { 10 | const teamName = useRouter().query.name as string; 11 | const [versus, setVersus] = useState(null); 12 | const { data: team, isLoading } = trpc.teams.byId.useQuery({ 13 | name: teamName, 14 | }); 15 | const { data: teams } = trpc.teams.list.useQuery({ 16 | teamsize: team?.teamsize || 1, 17 | }); 18 | const { data: versusTeam } = trpc.teams.byId.useQuery( 19 | { 20 | name: versus || "", 21 | }, 22 | { 23 | keepPreviousData: true, 24 | enabled: !!versus && versus !== "", 25 | }, 26 | ); 27 | const selectedSeason = useStore(state => state.season); 28 | const { data: matches } = trpc.matches.list.useQuery({ 29 | team1: teamName, 30 | team2: versus !== "" ? versusTeam?.name : undefined, 31 | limit: 0, 32 | season: selectedSeason, 33 | }); 34 | 35 | if (!team || isLoading) { 36 | return ( 37 |
38 | 39 |
40 | ); 41 | } 42 | 43 | const onVersusSelect = async (teamName: string) => { 44 | setVersus(teamName); 45 | }; 46 | 47 | return ( 48 | <> 49 | 50 | 57 | 58 | ); 59 | }; 60 | 61 | export default Team; 62 | -------------------------------------------------------------------------------- /src/pages/teams/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { NextSeo } from "next-seo"; 3 | import { trpc } from "~/utils/trpc"; 4 | import { Select } from "~/components/Form"; 5 | import { Card, LoadingIndicator, TeamTable } from "~/components/Elements"; 6 | 7 | const Teams = () => { 8 | const [teamsize, setTeamsize] = useState(1); 9 | const { data, isLoading } = trpc.teams.list.useQuery( 10 | { teamsize }, 11 | { keepPreviousData: true }, 12 | ); 13 | 14 | return ( 15 | <> 16 | 17 |
18 |