├── .github └── workflows │ └── csgo-tracker.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── electronapp ├── .eslintrc ├── .prettierrc ├── buildResources │ ├── icon.ico │ └── icon.png ├── main.js ├── package-lock.json └── package.json ├── gamestate_integration_stats.cfg ├── icon.svg ├── restapi ├── .eslintrc ├── .prettierrc ├── config.ts ├── copyAssets.ts ├── models │ ├── match.ts │ └── round.ts ├── package-lock.json ├── package.json ├── routes │ ├── rgame.ts │ ├── rmatch.ts │ ├── rstats.ts │ └── rsteamapi.ts ├── schema.sql ├── server.ts ├── services │ ├── matchService.ts │ ├── roundService.ts │ └── statsService.ts ├── statsCache.ts └── tsconfig.json ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── webapp ├── .prettierrc ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── img │ ├── death.png │ ├── github-logo.png │ ├── icon.svg │ ├── kill.png │ ├── killhs.png │ ├── map-icons │ ├── map_icon_ar_baggage.svg │ ├── map_icon_ar_dizzy.svg │ ├── map_icon_ar_lunacy.svg │ ├── map_icon_ar_monastery.svg │ ├── map_icon_ar_shoots.svg │ ├── map_icon_coop_kasbah.svg │ ├── map_icon_cs_agency.svg │ ├── map_icon_cs_assault.svg │ ├── map_icon_cs_insertion.svg │ ├── map_icon_cs_italy.svg │ ├── map_icon_cs_militia.svg │ ├── map_icon_cs_office.svg │ ├── map_icon_cs_workout.svg │ ├── map_icon_de_abbey.svg │ ├── map_icon_de_anubis.svg │ ├── map_icon_de_austria.svg │ ├── map_icon_de_aztec.svg │ ├── map_icon_de_bank.svg │ ├── map_icon_de_biome.svg │ ├── map_icon_de_breach.svg │ ├── map_icon_de_cache.svg │ ├── map_icon_de_canals.svg │ ├── map_icon_de_cbble.svg │ ├── map_icon_de_chlorine.svg │ ├── map_icon_de_dust.svg │ ├── map_icon_de_dust2.svg │ ├── map_icon_de_inferno.svg │ ├── map_icon_de_lake.svg │ ├── map_icon_de_mirage.svg │ ├── map_icon_de_mutiny.svg │ ├── map_icon_de_nuke.svg │ ├── map_icon_de_overpass.svg │ ├── map_icon_de_ruby.svg │ ├── map_icon_de_safehouse.svg │ ├── map_icon_de_seaside.svg │ ├── map_icon_de_shipped.svg │ ├── map_icon_de_shortdust.svg │ ├── map_icon_de_shortnuke.svg │ ├── map_icon_de_stmarc.svg │ ├── map_icon_de_studio.svg │ ├── map_icon_de_subzero.svg │ ├── map_icon_de_sugarcane.svg │ ├── map_icon_de_swamp.svg │ ├── map_icon_de_train.svg │ ├── map_icon_de_vertigo.svg │ ├── map_icon_de_zoo.svg │ ├── map_icon_dz_blacksite.svg │ ├── map_icon_dz_junglety.svg │ ├── map_icon_dz_sirocco.svg │ ├── map_icon_gd_cbble.svg │ └── map_icon_gd_rialto.svg │ ├── maps-opaque │ ├── ar_baggage.jpg │ ├── ar_dizzy.jpg │ ├── ar_monastery.jpg │ ├── ar_shoots.jpg │ ├── cs_agency.jpg │ ├── cs_assault.jpg │ ├── cs_italy.jpg │ ├── cs_militia.jpg │ ├── cs_office.jpg │ ├── de_ancient.jpg │ ├── de_bank.jpg │ ├── de_cache.jpg │ ├── de_canals.jpg │ ├── de_cbble.jpg │ ├── de_dust2.jpg │ ├── de_inferno.jpg │ ├── de_lake.jpg │ ├── de_mirage.jpg │ ├── de_nuke.jpg │ ├── de_overpass.jpg │ ├── de_safehouse.jpg │ ├── de_shortnuke.jpg │ ├── de_stmarc.jpg │ ├── de_sugarcane.jpg │ ├── de_train.jpg │ ├── de_vertigo.jpg │ └── gd_cbble.jpg │ └── match-in-progress.svg ├── src ├── App.vue ├── api │ └── api.js ├── components │ ├── NavBar.vue │ ├── match │ │ ├── MatchDetails.vue │ │ ├── MatchPreview.vue │ │ ├── MatchesView.vue │ │ ├── MoneyChart.vue │ │ └── RoundsChart.vue │ └── stats │ │ ├── BarChart.vue │ │ ├── DonutChart.vue │ │ └── StatsDashboard.vue ├── main.js └── util │ ├── palette.js │ └── popover.js └── vite.config.js /.github/workflows/csgo-tracker.yml: -------------------------------------------------------------------------------- 1 | name: CI for csgo-tracker 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | build-electronapp-windows: 9 | runs-on: windows-latest 10 | defaults: 11 | run: 12 | working-directory: electronapp 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20.18.0 18 | - name: Install restapi dependencies 19 | run: | 20 | cd ../restapi 21 | npm ci 22 | npm install -g typescript 23 | echo "${{secrets.RESTAPI_SECRETS}}" | Out-File secrets.ts 24 | - name: Build restapi 25 | run: | 26 | cd ../restapi 27 | npm run build 28 | - name: Install webapp dependencies 29 | run: | 30 | cd ../webapp 31 | npm ci 32 | - name: Build webapp 33 | run: | 34 | cd ../webapp 35 | npm run build 36 | - name: Install electronapp dependencies 37 | run: | 38 | npm i 39 | New-Item -Name "resources" -ItemType "directory" 40 | Copy-Item -Path ../webapp/dist/* -Destination resources -Recurse 41 | - name: Build electronapp 42 | run: npm run dist 43 | - name: Bundle installer and csgo config file 44 | run: | 45 | New-Item -Name "bundle" -ItemType "directory" 46 | cp ./dist/csgo-tracker-installer.exe ./bundle/ 47 | cp ../gamestate_integration_stats.cfg ./bundle/ 48 | - name: Compress electronapp 49 | run: 7z a -tzip csgo-tracker-windows.zip ./bundle/* 50 | - name: Upload electronapp 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: csgo-tracker-windows.zip 54 | path: electronapp/csgo-tracker-windows.zip 55 | build-electronapp-linux: 56 | runs-on: ubuntu-latest 57 | defaults: 58 | run: 59 | working-directory: electronapp 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: 20.18.0 65 | - name: Install restapi dependencies 66 | run: | 67 | cd ../restapi 68 | npm ci 69 | npm install -g typescript 70 | echo "${{secrets.RESTAPI_SECRETS}}" > secrets.ts 71 | - name: Build restapi 72 | run: | 73 | cd ../restapi 74 | npm run build 75 | - name: Install webapp dependencies 76 | run: | 77 | cd ../webapp 78 | npm ci 79 | - name: Build webapp 80 | run: | 81 | cd ../webapp 82 | npm run build 83 | - name: Install electronapp dependencies 84 | run: | 85 | npm i 86 | mkdir resources 87 | cp -r ../webapp/dist/* resources/ 88 | - name: Build electronapp 89 | run: npm run dist 90 | - name: Bundle executable and csgo config file 91 | run: | 92 | mkdir bundle 93 | cp ./dist/csgo-tracker ./bundle/ 94 | cp ../gamestate_integration_stats.cfg ./bundle/ 95 | - name: Compress electronapp 96 | run: 7z a -tzip csgo-tracker-linux.zip ./bundle/* 97 | - name: Upload electronapp 98 | uses: actions/upload-artifact@v4 99 | with: 100 | name: csgo-tracker-linux.zip 101 | path: electronapp/csgo-tracker-linux.zip 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /restapi/node_modules/ 2 | /restapi/dist/ 3 | /restapi/stats.db* 4 | /webapp/node_modules/ 5 | /webapp/dist/ 6 | /restapi/secrets.ts 7 | /.vscode/ 8 | vetur.config.js 9 | /electronapp/node_modules/ 10 | /electronapp/resources/ 11 | /electronapp/dist/ 12 | /electronapp/build/ 13 | iconTemp.svg 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Álvarez Fidalgo 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # csgo-tracker 3 | ![CI for csgo-tracker](https://github.com/davidaf3/csgo-tracker/actions/workflows/csgo-tracker.yml/badge.svg) 4 | 5 | csgo-tracker is a simple [Electron](https://www.electronjs.org/) app that lets you track your Counter-Strike 2 matches and stats using [Valve's game state integration](https://developer.valvesoftware.com/wiki/Counter-Strike:_Global_Offensive_Game_State_Integration). 6 | 7 | ## Features 8 | - Keep track of your CS2 stats and matches. 9 | - Get real time updates about your performance in the current match. 10 | - No ads, no account needed, everything is stored locally. 11 | 12 | screenshot1 13 | screenshot2 14 | screenshot3 15 | 16 | ## Installation 17 | You can download the latest stable release for your OS [here](https://github.com/davidaf3/csgo-tracker/releases). Also, you can download the latest build from the [Actions tab](https://github.com/davidaf3/csgo-tracker/actions) (you have to be logged into your GitHub account). 18 | 19 | The downloaded .zip folder will contain a .cfg file and an executable file. Before installing or running the app you need to place the .cfg file into the game/csgo/cfg directory inside the CS2 installation folder. If your game was running when you placed the file, make sure to restart it to apply the configuration changes. 20 | 21 | To install the Windows version, you have to run the installer file. The Linux version is an [AppImage](https://appimage.org/) so you can just run the executable to start the app. 22 | 23 | ## Usage 24 | To track a match, you have to run csgo-tracker **before** starting the match. If you close the app before the match ends, the match stats will be incomplete. 25 | 26 | To access your match history, go to the Matches tab. A list of matches will appear on the left hand side of the screen. Then you can click on any item of the list to see your performance in each match. You can also see your performance in any round by clicking on the chart at the bottom of the screen. 27 | 28 | ## Repository Structure 29 | The app has two main components: 30 | - A REST API (restapi folder) built using Node.js and Express. It receives, processes and stores the game info into an SQLite database. It also exposes endpoints to get the matches, rounds and stats stored in the database. 31 | - A web app (webapp folder) built with Vue.js. It fetches info from the REST API and displays it to the user. 32 | 33 | These two components are bundled together into an Electron app (electronapp folder) in order to easily distribute them. 34 | -------------------------------------------------------------------------------- /electronapp/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parserOptions": { 4 | "ecmaVersion": "latest" 5 | }, 6 | "rules": { 7 | "linebreak-style": "off", 8 | "operator-linebreak": "off", 9 | "implicit-arrow-linebreak": "off", 10 | "comma-dangle": [ 11 | "error", 12 | { 13 | "arrays": "always-multiline", 14 | "objects": "always-multiline", 15 | "imports": "never", 16 | "exports": "never", 17 | "functions": "never" 18 | } 19 | ], 20 | "import/no-extraneous-dependencies": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /electronapp/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /electronapp/buildResources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/electronapp/buildResources/icon.ico -------------------------------------------------------------------------------- /electronapp/buildResources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/electronapp/buildResources/icon.png -------------------------------------------------------------------------------- /electronapp/main.js: -------------------------------------------------------------------------------- 1 | const { 2 | app, protocol, BrowserWindow, shell, screen, 3 | } = require('electron'); 4 | const path = require('path'); 5 | const os = require('os'); 6 | 7 | // Start express app 8 | const startRestAPI = require('csgo-tracker-restapi').default; 9 | 10 | const WIDTH = 1776; 11 | const HEIGHT = 1000; 12 | 13 | startRestAPI(path.join(os.homedir(), '.csgo-tracker', 'stats.db')); 14 | 15 | function createWindow() { 16 | const FILE_PROTOCOL = 'file'; 17 | 18 | protocol.interceptFileProtocol(FILE_PROTOCOL, (request, callback) => { 19 | let url = request.url.substring(FILE_PROTOCOL.length + 1); 20 | url = path.join(__dirname, 'resources', url); 21 | url = path.normalize(url); 22 | callback({ path: url }); 23 | }); 24 | 25 | const { screenWidth, screenHeight } = screen.getPrimaryDisplay().workArea; 26 | 27 | const window = new BrowserWindow({ 28 | show: false, 29 | backgroundColor: '#282c34', 30 | width: WIDTH, 31 | height: HEIGHT, 32 | x: screenWidth / 2 - WIDTH / 2, 33 | y: screenHeight / 2 - HEIGHT / 2, 34 | }); 35 | 36 | window.webContents.setWindowOpenHandler((details) => { 37 | if (/^https:\/\//.test(details.url)) shell.openExternal(details.url); 38 | return { action: 'deny' }; 39 | }); 40 | 41 | window.removeMenu(); 42 | window.loadURL('file:///index.html'); 43 | 44 | window.once('ready-to-show', () => { 45 | window.show(); 46 | }); 47 | } 48 | 49 | app.whenReady().then(() => { 50 | createWindow(); 51 | }); 52 | -------------------------------------------------------------------------------- /electronapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csgo-tracker", 3 | "version": "0.2.0", 4 | "description": "Electron app", 5 | "main": "main.js", 6 | "build": { 7 | "appId": "csgotracker", 8 | "productName": "csgo-tracker", 9 | "files": [ 10 | "!.prettierrc", 11 | "!.eslintrc", 12 | "!.stats.db*" 13 | ], 14 | "directories": { 15 | "buildResources": "buildResources" 16 | }, 17 | "nsis": { 18 | "artifactName": "csgo-tracker-installer.exe" 19 | }, 20 | "linux": { 21 | "executableName": "csgo-tracker", 22 | "desktop": { 23 | "Type": "Application", 24 | "Name": "csgo-tracker" 25 | } 26 | }, 27 | "appImage": { 28 | "artifactName": "csgo-tracker" 29 | } 30 | }, 31 | "scripts": { 32 | "start": "electron .", 33 | "test": "echo \"Error: no test specified\" && exit 1", 34 | "pack": "electron-builder --dir", 35 | "dist": "electron-builder", 36 | "dist-portable": "electron-builder --win portable", 37 | "postinstall": "electron-builder install-app-deps" 38 | }, 39 | "author": { 40 | "name": "David Álvarez Fidalgo", 41 | "email": "david.alvarez.fidalgo@gmail.com" 42 | }, 43 | "homepage": "https://github.com/davidaf3/csgo-tracker", 44 | "license": "MIT", 45 | "dependencies": { 46 | "csgo-tracker-restapi": "file:../restapi" 47 | }, 48 | "devDependencies": { 49 | "electron": "^v33.0.2", 50 | "electron-builder": "^25.1.8", 51 | "eslint": "^8.24.0", 52 | "eslint-config-airbnb": "^19.0.4" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /gamestate_integration_stats.cfg: -------------------------------------------------------------------------------- 1 | "csgo-tracker" 2 | { 3 | "uri" "http://localhost:8090/game" 4 | "timeout" "5.0" 5 | "buffer" "0.1" 6 | "throttle" "0.1" 7 | "heartbeat" "30.0" 8 | "data" 9 | { 10 | "map_round_wins" "1" // history of round wins 11 | "map" "1" // mode, map, phase, team scores 12 | "player_id" "1" // steamid 13 | "player_match_stats" "1" // scoreboard info 14 | "player_state" "1" // armor, flashed, equip_value, health, etc. 15 | "player_weapons" "1" // list of player weapons and weapon state 16 | "provider" "1" // info about the game providing info 17 | "round" "1" // round phase and the winning team 18 | } 19 | } -------------------------------------------------------------------------------- /restapi/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parserOptions": { 4 | "ecmaVersion": "latest" 5 | }, 6 | "rules": { 7 | "linebreak-style": "off", 8 | "operator-linebreak": "off", 9 | "implicit-arrow-linebreak": "off", 10 | "comma-dangle": [ 11 | "error", 12 | { 13 | "arrays": "always-multiline", 14 | "objects": "always-multiline", 15 | "imports": "never", 16 | "exports": "never", 17 | "functions": "never" 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /restapi/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /restapi/config.ts: -------------------------------------------------------------------------------- 1 | interface Config { 2 | /** Database file name */ 3 | dbFile: string; 4 | } 5 | 6 | export let config: Config = { 7 | dbFile: 'stats.db', 8 | }; 9 | -------------------------------------------------------------------------------- /restapi/copyAssets.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | fs.copyFile('schema.sql', 'dist/schema.sql'); 4 | -------------------------------------------------------------------------------- /restapi/models/match.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'sqlite3'; 2 | import { config } from './../config'; 3 | 4 | export interface Match { 5 | /** Id of the match */ 6 | id: string; 7 | 8 | /** Id of the player */ 9 | playerId: string; 10 | 11 | /** Name of the map */ 12 | map: string; 13 | 14 | /** Name of the gamemode */ 15 | mode: string; 16 | 17 | /** Date of the match */ 18 | date: Date; 19 | 20 | /** Duration in seconds */ 21 | duration: number; 22 | 23 | /** Number of rounds won */ 24 | roundsWon: number; 25 | 26 | /** Number of rounds lost */ 27 | roundsLost: number; 28 | 29 | /** Number of kills */ 30 | kills: number; 31 | 32 | /** Number of headshot kills */ 33 | killshs: number; 34 | 35 | /** Number of assists */ 36 | assists: number; 37 | 38 | /** Number of deaths */ 39 | deaths: number; 40 | 41 | /** Number of mvps won */ 42 | mvps: number; 43 | 44 | /** Player score */ 45 | score: number; 46 | 47 | /** Whether the match is over or not */ 48 | over: boolean; 49 | } 50 | 51 | /** 52 | * Adds a match 53 | * @param match match to add 54 | * @returns promise that resolves when the match is added 55 | */ 56 | export function add(match: Match): Promise { 57 | const db = new Database(config.dbFile); 58 | return new Promise((resolve, reject) => { 59 | const statement = db.prepare( 60 | 'INSERT INTO Matches ' + 61 | '(id, player_id, map, mode, date, duration_seconds, rounds_won, rounds_lost, kills, killshs, deaths, assists, score, mvps, over) ' + 62 | 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' 63 | ); 64 | statement 65 | .bind([ 66 | match.id, 67 | match.playerId, 68 | match.map, 69 | match.mode, 70 | match.date.toISOString(), 71 | match.duration, 72 | match.roundsWon, 73 | match.roundsLost, 74 | match.kills, 75 | match.killshs, 76 | match.deaths, 77 | match.assists, 78 | match.score, 79 | match.mvps, 80 | match.over, 81 | ]) 82 | .run((err) => { 83 | statement.finalize(); 84 | db.close(); 85 | if (err) reject(err); 86 | resolve(); 87 | }); 88 | }); 89 | } 90 | 91 | /** 92 | * Updates a match 93 | * @param match updated match 94 | * @returns promise that resolves when the update is done 95 | */ 96 | export function update(match: Match): Promise { 97 | const db = new Database(config.dbFile); 98 | return new Promise((resolve, reject) => { 99 | const statement = db.prepare( 100 | 'UPDATE Matches SET player_id = ?, map = ?, mode = ?, date = ?, duration_seconds = ?, rounds_won = ?, ' + 101 | 'rounds_lost = ?, kills = ?, killshs = ?, deaths = ?, assists = ?, score = ?, mvps = ?, over = ? ' + 102 | 'WHERE id = ?' 103 | ); 104 | statement 105 | .bind([ 106 | match.playerId, 107 | match.map, 108 | match.mode, 109 | match.date.toISOString(), 110 | match.duration, 111 | match.roundsWon, 112 | match.roundsLost, 113 | match.kills, 114 | match.killshs, 115 | match.deaths, 116 | match.assists, 117 | match.score, 118 | match.mvps, 119 | match.over, 120 | match.id, 121 | ]) 122 | .run((err) => { 123 | statement.finalize(); 124 | db.close(); 125 | if (err) reject(err); 126 | resolve(); 127 | }); 128 | }); 129 | } 130 | 131 | /** 132 | * Deletes a match 133 | * @param id id of the match 134 | */ 135 | export function deleteMatch(id: string): void { 136 | const db = new Database(config.dbFile); 137 | db.serialize(() => { 138 | db.exec('PRAGMA foreign_keys = ON') 139 | .prepare('DELETE FROM Matches WHERE id = ?') 140 | .bind([id]) 141 | .run() 142 | .finalize(); 143 | }); 144 | db.close(); 145 | } 146 | 147 | /** 148 | * Gets all the matches asynchronously, ordered by date 149 | * @return promise that resolves to the list of matches 150 | */ 151 | export function getAll(): Promise { 152 | return new Promise((resolve, reject) => { 153 | const db = new Database(config.dbFile); 154 | db.all('SELECT * FROM MATCHES ORDER BY date DESC', (err, rows) => { 155 | db.close(); 156 | if (err) reject(err); 157 | resolve(rows.map(rowToMatch)); 158 | }); 159 | }); 160 | } 161 | 162 | /** 163 | * Gets a match by id asynchronously 164 | * @param {string} id id of the match 165 | * @return {Promise} promise that resolves to the match 166 | * or null if there is no such match 167 | */ 168 | export function get(id: string): Promise { 169 | return new Promise((resolve, reject) => { 170 | const db = new Database(config.dbFile); 171 | db.prepare('SELECT * FROM MATCHES WHERE id = ?') 172 | .bind([id]) 173 | .get((err, row) => { 174 | db.close(); 175 | if (err) reject(err); 176 | resolve(row ? rowToMatch(row) : null); 177 | }) 178 | .finalize(); 179 | }); 180 | } 181 | 182 | /** 183 | * Maps a row to a match 184 | * @param row row to map 185 | * @returns match object 186 | */ 187 | function rowToMatch(row: any): Match { 188 | return { 189 | id: row.id, 190 | playerId: row.player_id, 191 | map: row.map, 192 | mode: row.mode, 193 | date: new Date(row.date), 194 | duration: row.duration_seconds, 195 | roundsWon: row.rounds_won, 196 | roundsLost: row.rounds_lost, 197 | kills: row.kills, 198 | killshs: row.killshs, 199 | deaths: row.deaths, 200 | assists: row.assists, 201 | score: row.score, 202 | mvps: row.mvps, 203 | over: row.over === 1, 204 | }; 205 | } 206 | -------------------------------------------------------------------------------- /restapi/models/round.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'sqlite3'; 2 | import { config } from './../config'; 3 | 4 | export interface Round { 5 | /** Id of the round */ 6 | id: string; 7 | 8 | /** Id of the match */ 9 | matchId: string; 10 | 11 | /** Round number */ 12 | n: number; 13 | 14 | /** Player's team */ 15 | team: string; 16 | 17 | /** Equipment value */ 18 | equipValue: number; 19 | 20 | /** Amount of money at the beginning of the round */ 21 | initMoney: number; 22 | 23 | /** Armor at the beginning of the round */ 24 | initArmor: number; 25 | 26 | /** Whether the player has helmet on */ 27 | helmet: boolean; 28 | 29 | /** Duration of the round in seconds */ 30 | duration: number; 31 | 32 | /** Winner team */ 33 | winner: string; 34 | 35 | /** String indicating how the round ended */ 36 | winType: string; 37 | 38 | /** Whether the player died */ 39 | died: boolean; 40 | 41 | /** Number of kills */ 42 | kills: number; 43 | 44 | /** Number of headshot kills */ 45 | killshs: number; 46 | 47 | /** Number of assists */ 48 | assists: number; 49 | 50 | /** Player score */ 51 | score: number; 52 | 53 | /** Whether the player was the mvp */ 54 | mvp: boolean; 55 | } 56 | 57 | /** 58 | * Adds a round 59 | * @param round round to add 60 | * @returns promise that resolves when the round is added 61 | */ 62 | export function add(round: Round): Promise { 63 | const db = new Database(config.dbFile); 64 | return new Promise((resolve, reject) => { 65 | db.serialize(() => { 66 | db.exec('PRAGMA foreign_keys = ON'); 67 | const statement = db.prepare( 68 | 'INSERT INTO Rounds ' + 69 | '(id, match_id, n_round, team, equip_value, init_money, init_armor, helmet,' + 70 | 'duration_seconds, winner, win_type, died, kills, killshs, assists, score, mvp)' + 71 | 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' 72 | ); 73 | statement 74 | .bind([ 75 | round.id, 76 | round.matchId, 77 | round.n, 78 | round.team, 79 | round.equipValue, 80 | round.initMoney, 81 | round.initArmor, 82 | round.helmet, 83 | round.duration, 84 | round.winner, 85 | round.winType, 86 | round.died, 87 | round.kills, 88 | round.killshs, 89 | round.assists, 90 | round.score, 91 | round.mvp, 92 | ]) 93 | .run((err) => { 94 | statement.finalize(); 95 | db.close(); 96 | if (err) reject(err); 97 | resolve(); 98 | }); 99 | }); 100 | }); 101 | } 102 | 103 | /** 104 | * Gets all the rounds asynchronously 105 | * @return promise that resolves to the list of rounds 106 | */ 107 | export function getAll(): Promise { 108 | return new Promise((resolve, reject) => { 109 | const db = new Database(config.dbFile); 110 | db.all('SELECT * FROM ROUNDS', (err, rows) => { 111 | db.close(); 112 | if (err) reject(err); 113 | resolve(rows.map(rowToRound)); 114 | }); 115 | }); 116 | } 117 | 118 | /** 119 | * Gets all the rounds of a match, ordered by round number 120 | * @param matchId id of the match 121 | * @return promise that resolves to the list of rounds 122 | */ 123 | export function getByMatch(matchId: string): Promise { 124 | return new Promise((resolve, reject) => { 125 | const db = new Database(config.dbFile); 126 | db.prepare('SELECT * FROM ROUNDS WHERE match_id = ? ORDER BY n_round') 127 | .bind([matchId]) 128 | .all((err, rows) => { 129 | db.close(); 130 | if (err) reject(err); 131 | resolve(rows.map(rowToRound)); 132 | }) 133 | .finalize(); 134 | }); 135 | } 136 | 137 | /** 138 | * Gets a rounds of a match 139 | * @param matchId id of the match 140 | * @param number of the round 141 | * @return promise that resolves to a round or null if there is no such round 142 | */ 143 | export function getByMatchAndNumber( 144 | matchId: string, 145 | n: string 146 | ): Promise { 147 | return new Promise((resolve, reject) => { 148 | const db = new Database(config.dbFile); 149 | db.prepare('SELECT * FROM ROUNDS WHERE match_id = ? AND n_round = ?') 150 | .bind([matchId, n]) 151 | .get((err, row) => { 152 | db.close(); 153 | if (err) reject(err); 154 | resolve(row ? rowToRound(row) : null); 155 | }) 156 | .finalize(); 157 | }); 158 | } 159 | 160 | /** 161 | * Maps a row to a round 162 | * @param row row to map 163 | * @returns round object 164 | */ 165 | function rowToRound(row: any): Round { 166 | return { 167 | id: row.id, 168 | matchId: row.match_id, 169 | n: row.n_round, 170 | team: row.team, 171 | equipValue: row.equip_value, 172 | initMoney: row.init_money, 173 | initArmor: row.init_armor, 174 | helmet: row.helmet === 1, 175 | duration: row.duration_seconds, 176 | winner: row.winner, 177 | winType: row.win_type, 178 | died: row.died === 1, 179 | kills: row.kills, 180 | killshs: row.killshs, 181 | assists: row.assists, 182 | score: row.score, 183 | mvp: row.mvp === 1, 184 | }; 185 | } 186 | -------------------------------------------------------------------------------- /restapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csgo-tracker-restapi", 3 | "version": "0.2.0", 4 | "description": "", 5 | "main": "dist/server.js", 6 | "scripts": { 7 | "build": "tsc && node dist/copyAssets.js", 8 | "start": "node dist/server.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "express": "^4.21.1", 15 | "sqlite3": "^5.1.7", 16 | "ws": "^8.18.0" 17 | }, 18 | "devDependencies": { 19 | "@types/express": "^5.0.0", 20 | "@types/node": "^20.14.8", 21 | "@types/ws": "^8.5.12", 22 | "eslint": "^8.24.0", 23 | "eslint-config-airbnb": "^19.0.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /restapi/routes/rgame.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import { Express } from 'express'; 3 | import * as statsCache from '../statsCache' 4 | import * as matchService from '../services/matchService'; 5 | import * as roundService from '../services/roundService'; 6 | 7 | export default function(app: Express): EventEmitter { 8 | const gameEventEmitter = new EventEmitter(); 9 | 10 | app.post('/game', (req, res) => { 11 | const currentMatch = matchService.getCurrentMatch(); 12 | const currentRound = roundService.getCurrentRound(); 13 | 14 | // Match start 15 | if ( 16 | !currentMatch && 17 | req.body.added?.player?.match_stats && 18 | req.body.map.mode === 'competitive' 19 | ) { 20 | console.log('MATCH STARTED'); 21 | 22 | matchService 23 | .createMatch({ 24 | playerId: req.body.player.steamid, 25 | map: req.body.map.name, 26 | mode: req.body.map.mode, 27 | duration: 0, 28 | over: false, 29 | roundsWon: 0, 30 | roundsLost: 0, 31 | killshs: 0, 32 | ...req.body.player.match_stats, 33 | }) 34 | .then((id) => { 35 | gameEventEmitter.emit('game-event', 'match started', id); 36 | }); 37 | 38 | roundService.setNextRoundInitMoney(800); 39 | } 40 | 41 | // Round start 42 | if ( 43 | currentMatch && 44 | req.body.previously?.round?.phase === 'freezetime' && 45 | req.body.round?.phase === 'live' && 46 | req.body.map?.phase !== 'warmup' 47 | ) { 48 | console.log('ROUND STARTED'); 49 | 50 | const round = { 51 | matchId: currentMatch.id, 52 | n: req.body.map.round + 1, 53 | team: req.body.player.team, 54 | equipValue: req.body.player.state.equip_value, 55 | initArmor: req.body.player.state.armor, 56 | helmet: req.body.player.state.helmet, 57 | duration: 0, 58 | winner: '', 59 | winType: '', 60 | died: false, 61 | kills: 0, 62 | killshs: 0, 63 | assists: 0, 64 | score: 0, 65 | mvp: false, 66 | }; 67 | roundService.createRound(round); 68 | 69 | gameEventEmitter.emit('game-event', 'round started', currentMatch.id); 70 | } 71 | 72 | // Player death 73 | if ( 74 | currentMatch && 75 | currentRound && 76 | req.body.player?.state?.health === 0 && 77 | req.body.previously?.player?.state?.health > 0 && 78 | req.body.player?.steamid === currentMatch.playerId && 79 | req.body.map?.phase !== 'warmup' 80 | ) { 81 | console.log('YOU DIED'); 82 | 83 | const roundInfo = { 84 | died: true, 85 | killshs: req.body.player.state.round_killhs ?? 0, 86 | kills: req.body.player.match_stats.kills - currentMatch.kills, 87 | assists: req.body.player.match_stats.assists - currentMatch.assists, 88 | score: req.body.player.match_stats.score - currentMatch.score, 89 | mvp: req.body.player.match_stats.mvps > currentMatch.mvps, 90 | }; 91 | roundService.updateCurrentRound(roundInfo); 92 | matchService.updateCurrentMatch({ 93 | deaths: currentMatch.deaths + 1, 94 | }); 95 | 96 | gameEventEmitter.emit('game-event', 'you died', currentMatch.id); 97 | } 98 | 99 | // Round end 100 | if ( 101 | currentMatch && 102 | currentRound && 103 | req.body.previously?.round?.phase === 'over' && 104 | req.body.round?.phase === 'freezetime' && 105 | req.body.map?.round > 0 && 106 | req.body.map?.phase !== 'warmup' 107 | ) { 108 | console.log('ROUND ENDED'); 109 | 110 | const winString = req.body.map.round_wins 111 | ? req.body.map.round_wins[req.body.map.round.toString()] 112 | : `${req.body.round.win_team.toLowerCase()}_win`; 113 | 114 | const roundInfo = { 115 | kills: req.body.player.match_stats.kills - currentMatch.kills, 116 | killshs: currentRound.killshs, 117 | assists: req.body.player.match_stats.assists - currentMatch.assists, 118 | winner: winString.split('_')[0].toUpperCase(), 119 | winType: winString, 120 | score: req.body.player.match_stats.score - currentMatch.score, 121 | mvp: req.body.player.match_stats.mvps > currentMatch.mvps, 122 | duration: 123 | (new Date().getTime() - 124 | roundService.getCurrentRoundInitDate().getTime()) / 125 | 1000, 126 | }; 127 | 128 | // Add hs if the previous player was the main player 129 | if ( 130 | !req.body.previously?.player?.steamid && 131 | req.body.previously?.player?.state?.round_killhs 132 | ) { 133 | roundInfo.killshs = req.body.previously?.player?.state?.round_killhs; 134 | } 135 | 136 | roundService.updateCurrentRound(roundInfo); 137 | matchService.updateCurrentMatch({ 138 | killshs: currentMatch.killshs + roundInfo.killshs, 139 | kills: req.body.player.match_stats.kills, 140 | assists: req.body.player.match_stats.assists, 141 | score: req.body.player.match_stats.score, 142 | mvps: req.body.player.match_stats.mvps, 143 | roundsWon: 144 | currentMatch.roundsWon + 145 | (roundInfo.winner === currentRound.team ? 1 : 0), 146 | roundsLost: 147 | currentMatch.roundsLost + 148 | (roundInfo.winner !== currentRound.team ? 1 : 0), 149 | duration: (new Date().getTime() - currentMatch.date.getTime()) / 1000, 150 | }); 151 | 152 | Promise.all([ 153 | roundService.saveCurrentRound(), 154 | matchService.saveCurrentMatch(), 155 | ]).then(() => { 156 | gameEventEmitter.emit('game-event', 'round ended', currentMatch.id); 157 | }); 158 | 159 | roundService.setNextRoundInitMoney(req.body.player.state.money); 160 | } 161 | 162 | // Match end and last round end 163 | if ( 164 | currentMatch && 165 | currentRound && 166 | req.body.map?.phase === 'gameover' && 167 | req.body.previously?.map?.phase === 'live' 168 | ) { 169 | console.log('ROUND ENDED'); 170 | 171 | const winString = req.body.map.round_wins 172 | ? req.body.map.round_wins[req.body.map.round.toString()] 173 | : `${req.body.round.win_team.toLowerCase()}_win`; 174 | 175 | let roundStats = {}; 176 | let matchStats = {}; 177 | 178 | // Add stats if the current player is the main player 179 | if (req.body.player?.steamid === currentMatch.playerId) { 180 | const roundKillshs = req.body.player?.state?.round_killhs ?? 0; 181 | roundStats = { 182 | killshs: roundKillshs, 183 | kills: req.body.player.match_stats.kills - currentMatch.kills, 184 | assists: req.body.player.match_stats.assists - currentMatch.assists, 185 | score: req.body.player.match_stats.score - currentMatch.score, 186 | mvp: req.body.player.match_stats.mvps > currentMatch.mvps, 187 | }; 188 | 189 | matchStats = { 190 | killshs: currentMatch.killshs + roundKillshs, 191 | kills: req.body.player.match_stats.kills, 192 | assists: req.body.player.match_stats.assists, 193 | score: req.body.player.match_stats.score, 194 | mvps: req.body.player.match_stats.mvps, 195 | }; 196 | } else { 197 | // If the player died, update the match with the round info stored when he died 198 | matchStats = { 199 | killshs: currentMatch.killshs + currentRound.killshs, 200 | kills: currentMatch.kills + currentRound.kills, 201 | assists: currentMatch.assists + currentRound.assists, 202 | score: currentMatch.score + currentRound.score, 203 | mvps: currentMatch.mvps + (currentRound.mvp ? 1 : 0), 204 | }; 205 | } 206 | 207 | const roundInfo = { 208 | ...roundStats, 209 | winner: winString.split('_')[0].toUpperCase(), 210 | winType: winString, 211 | duration: 212 | (new Date().getTime() - 213 | roundService.getCurrentRoundInitDate().getTime()) / 214 | 1000, 215 | }; 216 | 217 | matchService.updateCurrentMatch({ 218 | ...matchStats, 219 | roundsWon: 220 | currentMatch.roundsWon + 221 | (roundInfo.winner === currentRound.team ? 1 : 0), 222 | roundsLost: 223 | currentMatch.roundsLost + 224 | (roundInfo.winner !== currentRound.team ? 1 : 0), 225 | over: true, 226 | duration: (new Date().getTime() - currentMatch.date.getTime()) / 1000, 227 | }); 228 | roundService.updateCurrentRound(roundInfo); 229 | 230 | const finishedMatchId = currentMatch.id; 231 | Promise.all([ 232 | roundService.saveCurrentRound(), 233 | matchService.saveCurrentMatch(), 234 | ]).then(() => { 235 | statsCache.invalidate(); 236 | gameEventEmitter.emit('game-event', 'round ended', finishedMatchId); 237 | gameEventEmitter.emit('game-event', 'match over', finishedMatchId); 238 | }); 239 | 240 | console.log('MATCH OVER'); 241 | matchService.closeCurrentMatch(); 242 | } 243 | 244 | // Match quit 245 | if (currentMatch && req.body.previously?.map === true) { 246 | console.log('QUIT'); 247 | gameEventEmitter.emit('game-event', 'quit', currentMatch.id); 248 | 249 | /* if (!currentMatch.over) { 250 | matchService.deleteMatch(currentMatch.id); 251 | } */ 252 | } 253 | 254 | /* if (req.body.player?.steamid !== matchService.getCurrentMatch()?.playerId) { 255 | console.log(req.body); 256 | console.log("Previously:"); 257 | console.log(req.body.previously); 258 | console.log("Added:"); 259 | console.log(req.body.added); 260 | } */ 261 | 262 | res.sendStatus(200); 263 | }); 264 | 265 | return gameEventEmitter; 266 | }; 267 | -------------------------------------------------------------------------------- /restapi/routes/rmatch.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import * as matchService from '../services/matchService'; 3 | import * as roundService from '../services/roundService'; 4 | 5 | export default function (app: Express): void { 6 | app.get('/match', async (_req, res) => { 7 | try { 8 | const matches = await matchService.findAll(); 9 | res.status(200); 10 | res.json(matches); 11 | } catch (err) { 12 | res.status(500); 13 | res.json({ error: 'An error occurred while getting the match list' }); 14 | } 15 | }); 16 | 17 | app.get('/match/:id', async (req, res) => { 18 | try { 19 | const match = await matchService.findById(req.params.id); 20 | if (match) { 21 | res.status(200); 22 | res.json(match); 23 | } else { 24 | res.status(404); 25 | res.json({ error: 'Match not found' }); 26 | } 27 | } catch (err) { 28 | res.status(500); 29 | res.json({ error: 'An error occurred while getting the match' }); 30 | } 31 | }); 32 | 33 | app.get('/match/:id/round', async (req, res) => { 34 | try { 35 | const match = await matchService.findById(req.params.id); 36 | if (match) { 37 | const rounds = await roundService.findByMatch(req.params.id); 38 | res.status(200); 39 | res.json(rounds); 40 | } else { 41 | res.status(404); 42 | res.json({ error: 'Match not found' }); 43 | } 44 | } catch (err) { 45 | res.status(500); 46 | res.json({ error: 'An error occurred while getting the rounds' }); 47 | } 48 | }); 49 | 50 | app.get('/match/:id/round/:n', async (req, res) => { 51 | try { 52 | const round = await roundService.findByMatchAndNumber( 53 | req.params.id, 54 | req.params.n 55 | ); 56 | if (round) { 57 | res.status(200); 58 | res.json(round); 59 | } else { 60 | res.status(404); 61 | res.json({ error: 'Round not found' }); 62 | } 63 | } catch (err) { 64 | res.status(500); 65 | res.json({ error: 'An error occurred while getting the round' }); 66 | } 67 | }); 68 | 69 | app.post('/match/:id/forceEnd', async (req, res) => { 70 | try { 71 | const match = await matchService.forceMatchEnd( 72 | req.params.id 73 | ); 74 | if (match) { 75 | res.status(200); 76 | res.json(match); 77 | } else { 78 | res.status(404); 79 | res.json({ error: 'Match not found' }); 80 | } 81 | } catch (err) { 82 | res.status(500); 83 | res.json({ error: 'An error occurred while forcing the match end' }); 84 | } 85 | }); 86 | 87 | app.delete('/match/:id', (req, res) => { 88 | try { 89 | matchService.deleteMatch(req.params.id); 90 | res.status(200); 91 | res.send('Match deleted'); 92 | } catch (err) { 93 | res.status(500); 94 | res.json({ error: 'An error occurred while deleting the match' }); 95 | } 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /restapi/routes/rstats.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import { Stats, getAllStats } from '../services/statsService'; 3 | import * as statsCache from '../statsCache'; 4 | 5 | export default function (app: Express): void { 6 | app.get('/stats', async (req, res) => { 7 | try { 8 | let stats: Stats; 9 | if (statsCache.has(req.url)) { 10 | stats = statsCache.get(req.url) as Stats; 11 | } else { 12 | stats = await getAllStats(); 13 | statsCache.set(req.url, stats); 14 | } 15 | res.status(200); 16 | res.json(stats); 17 | } catch (err) { 18 | res.status(500); 19 | res.json({ error: 'An error occurred while getting the stats' }); 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /restapi/routes/rsteamapi.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import { STEAM_API_KEY } from '../secrets'; 3 | 4 | export default function (app: Express): void { 5 | app.get('/player/:id', async (req, res) => { 6 | try { 7 | const response = await fetch( 8 | `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${STEAM_API_KEY}&steamids=${req.params.id}` 9 | ); 10 | const result = (await response.json()) as any; 11 | res.status(200); 12 | res.send(result.response.players[0]); 13 | } catch (err) { 14 | res.status(500); 15 | res.json({ error: 'An error occurred while getting the player info' }); 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /restapi/schema.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = ON; 2 | 3 | create table Matches( 4 | id TEXT primary key not null, 5 | player_id TEXT not null, 6 | map TEXT not null, 7 | mode TEXT not null, 8 | date DATETIME not null, 9 | duration_seconds NUMBER not null, 10 | rounds_won NUMBER not null, 11 | rounds_lost NUMBER not null, 12 | kills NUMBER not null, 13 | killshs NUMBER not null, 14 | deaths NUMBER not null, 15 | assists NUMBER not null, 16 | score NUMBER not null, 17 | mvps NUMBER not null, 18 | over NUMBER not null 19 | ); 20 | 21 | create table Rounds( 22 | id TEXT primary key not null, 23 | match_id TEXT not null, 24 | n_round NUMBER not null, 25 | team TEXT not null, 26 | equip_value NUMBER not null, 27 | init_money NUMBER not null, 28 | init_armor NUMBER not null, 29 | helmet NUMBER not null, 30 | duration_seconds NUMBER not null, 31 | winner TEXT not null, 32 | win_type TEXT not null, 33 | died NUMBER not null, 34 | kills NUMBER not null, 35 | killshs NUMBER not null, 36 | assists NUMBER not null, 37 | score NUMBER not null, 38 | mvp NUMBER not null, 39 | FOREIGN KEY (match_id) REFERENCES Matches(id) ON DELETE CASCADE 40 | ); 41 | 42 | create index Index_Rounds_MatchID on Rounds(match_id); 43 | create index Index_Rounds_MatchID_NRound on Rounds(match_id, n_round); -------------------------------------------------------------------------------- /restapi/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { Database } from 'sqlite3'; 3 | import express from 'express'; 4 | import WebSocket from 'ws'; 5 | import path from 'path'; 6 | import { config } from './config'; 7 | import rgame from './routes/rgame'; 8 | import rmatch from './routes/rmatch'; 9 | import rstats from './routes/rstats'; 10 | import rsteamapi from './routes/rsteamapi'; 11 | 12 | const app = express(); 13 | 14 | app.use(express.json()); 15 | app.use(express.urlencoded({ extended: true })); 16 | 17 | app.use((_req, res, next) => { 18 | res.header('Access-Control-Allow-Origin', '*'); 19 | res.header('Access-Control-Allow-Credentials', 'true'); 20 | res.header('Access-Control-Allow-Methods', 'POST, GET, DELETE, UPDATE, PUT'); 21 | res.header( 22 | 'Access-Control-Allow-Headers', 23 | 'Origin, X-Requested-With, Content-Type, Accept, token' 24 | ); 25 | next(); 26 | }); 27 | 28 | const gameEventEmitter = rgame(app); 29 | rmatch(app); 30 | rstats(app); 31 | rsteamapi(app); 32 | 33 | const startSever = () => { 34 | const server = app.listen(8090, () => console.log('Server started')); 35 | 36 | const wss = new WebSocket.Server({ server }); 37 | wss.on('connection', (ws) => { 38 | const gameEventListener = (gameEvent: string, matchId: string) => { 39 | ws.send(JSON.stringify({ gameEvent, matchId })); 40 | }; 41 | gameEventEmitter.on('game-event', gameEventListener); 42 | 43 | ws.on('close', () => { 44 | gameEventEmitter.removeListener('game-event', gameEventListener); 45 | }); 46 | }); 47 | }; 48 | 49 | /** 50 | * Starts the rest api 51 | * @param dbFile path to the database file 52 | */ 53 | export default function startRestAPI(dbFile: string) { 54 | config.dbFile = dbFile; 55 | fs.access(dbFile) 56 | .then(startSever) 57 | .catch(async () => { 58 | // Creates the database for storing stats 59 | await fs.mkdir(path.dirname(dbFile), { recursive: true }); 60 | const db = new Database(dbFile); 61 | const schema = await fs.readFile(path.join(__dirname, 'schema.sql')); 62 | db.exec(schema.toString(), () => { 63 | startSever(); 64 | db.close(); 65 | }); 66 | }); 67 | } 68 | 69 | if (require.main === module) { 70 | startRestAPI(path.join(__dirname, 'stats.db')); 71 | } 72 | -------------------------------------------------------------------------------- /restapi/services/matchService.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto'; 2 | import * as Matches from '../models/match'; 3 | import { findByMatch, RoundState } from './roundService'; 4 | 5 | export type MatchState = Omit; 6 | 7 | let currentMatch: Matches.Match | null; 8 | 9 | /** 10 | * Creates a match 11 | * @param match match info 12 | * @returns promise that resolves to the new match id when the match is created 13 | */ 14 | export async function createMatch(match: MatchState): Promise { 15 | const id = randomUUID(); 16 | currentMatch = { 17 | id, 18 | date: new Date(), 19 | ...match, 20 | }; 21 | await Matches.add(currentMatch); 22 | return id; 23 | } 24 | 25 | /** 26 | * Returns the current match 27 | * @returns current match info 28 | */ 29 | export function getCurrentMatch(): Matches.Match | null { 30 | return currentMatch; 31 | } 32 | 33 | /** 34 | * Updates de current match 35 | * @param updatedMatch updated match info 36 | */ 37 | export function updateCurrentMatch(updatedMatch: Partial) { 38 | if (currentMatch) { 39 | currentMatch = { 40 | ...currentMatch, 41 | ...updatedMatch, 42 | }; 43 | } 44 | } 45 | 46 | /** 47 | * Saves the current match 48 | * @returns promise that resolves when the match is saved 49 | */ 50 | export function saveCurrentMatch(): Promise { 51 | if (currentMatch) return Matches.update(currentMatch); 52 | return new Promise((resolve) => resolve()); 53 | } 54 | 55 | /** 56 | * Closes the current match 57 | */ 58 | export function closeCurrentMatch(): void { 59 | currentMatch = null; 60 | } 61 | 62 | /** 63 | * Deletes a match by id 64 | * @param id match id 65 | */ 66 | export function deleteMatch(id: string): void { 67 | Matches.deleteMatch(id); 68 | if (id === currentMatch?.id) closeCurrentMatch(); 69 | } 70 | 71 | /** 72 | * Forces a match to end 73 | * @param id match id 74 | * @return promise that resolves to the finished match or to 75 | * null if the match was not found 76 | */ 77 | export async function forceMatchEnd(id: string): Promise { 78 | const match = await findById(id); 79 | if (!match || match.over) return match; 80 | 81 | const rounds = await findByMatch(id); 82 | if (rounds.length > match.roundsLost + match.roundsWon) { 83 | addRound(match, rounds[rounds.length - 1]); 84 | } 85 | 86 | match.over = true; 87 | if (match.id === currentMatch?.id) closeCurrentMatch(); 88 | Matches.update(match); 89 | return match; 90 | } 91 | 92 | /** 93 | * Adds a round to a match 94 | * @param match match 95 | * @param round round state to add 96 | */ 97 | function addRound(match: Matches.Match, round: RoundState): void { 98 | match.kills += round.kills; 99 | match.killshs += round.killshs; 100 | match.assists += round.assists; 101 | match.score += round.score; 102 | match.mvps += round.mvp ? 1 : 0; 103 | match.roundsWon += round.winner === round.team ? 1 : 0; 104 | match.roundsLost += round.winner !== round.team ? 1 : 0; 105 | match.duration += round.duration; 106 | } 107 | 108 | /** 109 | * Finds all matches asynchronously, ordered by date 110 | * @return promise that resolves to the list of matches 111 | */ 112 | export function findAll(): Promise { 113 | return Matches.getAll(); 114 | } 115 | 116 | /** 117 | * Finds a match by id 118 | * @param {string} id match id 119 | * @return {Promise} promise that resolves to the match 120 | * or null if there is no such match 121 | */ 122 | export async function findById(id: string): Promise { 123 | if (currentMatch && id === currentMatch.id) return currentMatch; 124 | return await Matches.get(id); 125 | } 126 | -------------------------------------------------------------------------------- /restapi/services/roundService.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto'; 2 | import * as Rounds from '../models/round'; 3 | 4 | export type RoundState = Omit; 5 | 6 | let currentRound: Rounds.Round | null; 7 | let currentRoundInitDate: Date; 8 | let nextRoundInitMoney: number | null; 9 | 10 | /** 11 | * @returns date at the start of the current round 12 | */ 13 | export function getCurrentRoundInitDate(): Date { 14 | return currentRoundInitDate; 15 | } 16 | 17 | /** 18 | * Creates a round 19 | * @param round round info 20 | * @returns id of the new round 21 | */ 22 | export function createRound(round: RoundState): string { 23 | currentRoundInitDate = new Date(); 24 | const id = randomUUID(); 25 | currentRound = { 26 | id, 27 | initMoney: nextRoundInitMoney ?? 800, 28 | ...round, 29 | }; 30 | return id; 31 | } 32 | 33 | /** 34 | * Sets the initial money for the next round 35 | * @param money initial money for the next round 36 | */ 37 | export function setNextRoundInitMoney(money: number) { 38 | nextRoundInitMoney = money; 39 | } 40 | 41 | /** 42 | * Returns the current round 43 | * @returns current round info 44 | */ 45 | export function getCurrentRound(): RoundState | null { 46 | return currentRound; 47 | } 48 | 49 | /** 50 | * Updates de current round 51 | * @param updatedRound updated round info 52 | */ 53 | export function updateCurrentRound(updatedRound: Partial): void { 54 | if (currentRound) { 55 | currentRound = { 56 | ...currentRound, 57 | ...updatedRound, 58 | }; 59 | } 60 | } 61 | 62 | /** 63 | * Saves the current round 64 | * @returns promise that resolves when the round is saved 65 | */ 66 | export function saveCurrentRound(): Promise { 67 | if (currentRound) return Rounds.add(currentRound); 68 | return new Promise((resolve) => resolve()); 69 | } 70 | 71 | /** 72 | * Finds all finished rounds of a match, ordered by round number 73 | * @param matchId id of the match 74 | * @returns list of rounds of the match 75 | */ 76 | export async function findByMatch(matchId: string): Promise { 77 | return Rounds.getByMatch(matchId); 78 | } 79 | 80 | /** 81 | * Finds a finished round of a match 82 | * @param matchId id of the match 83 | * @param n number of the round 84 | * @returns the round or null if there was no such round 85 | */ 86 | export async function findByMatchAndNumber( 87 | matchId: string, 88 | n: string 89 | ): Promise { 90 | return Rounds.getByMatchAndNumber(matchId, n); 91 | } 92 | -------------------------------------------------------------------------------- /restapi/services/statsService.ts: -------------------------------------------------------------------------------- 1 | import * as Matches from '../models/match'; 2 | import * as Rounds from '../models/round'; 3 | 4 | interface MatchesStats { 5 | /** Total kills */ 6 | kills: number; 7 | 8 | /** Total headshot kills */ 9 | killshs: number; 10 | 11 | /** Total assists */ 12 | assists: number; 13 | 14 | /** Total deaths */ 15 | deaths: number; 16 | 17 | /** Kill to death ratio */ 18 | kdr: number; 19 | 20 | /** Headshot kill ratio */ 21 | hsRate: number; 22 | 23 | /** Max kills in a single match */ 24 | maxKillsPerMatch: number; 25 | 26 | /** Total number of matches */ 27 | totalMatches: number; 28 | 29 | /** Total number of matches by result */ 30 | matchesByResult: { 31 | victory: number; 32 | defeat: number; 33 | tie: number; 34 | }; 35 | 36 | /** Number of matches by map */ 37 | matchesByMap: { 38 | [mapName: string]: number; 39 | }; 40 | 41 | /** Total number of seconds played */ 42 | totalDuration: number; 43 | 44 | /** Match win rate */ 45 | winRate: number; 46 | 47 | /** Average kills per match */ 48 | avgKillsPerMatch: number; 49 | 50 | /** Average match duration in seconds */ 51 | avgMatchDuration: number; 52 | 53 | /** Average match score */ 54 | avgScore: number; 55 | 56 | /** Average mvps per match */ 57 | avgMvps: number; 58 | } 59 | 60 | interface WinTypeStats { 61 | ct_win_elimination: number; 62 | ct_win_defuse: number; 63 | ct_win_time: number; 64 | t_win_elimination: number; 65 | t_win_bomb: number; 66 | } 67 | 68 | interface RoundsStats { 69 | /** Total number of rounds */ 70 | totalRounds: number; 71 | 72 | /** Total number of rounds won */ 73 | totalRoundsWon: number; 74 | 75 | /** Total number of rounds lost */ 76 | totalRoundsLost: number; 77 | 78 | /** Round win rate */ 79 | roundWinRate: number; 80 | 81 | /** Number of pistol rounds won */ 82 | pistolRoundsWon: number; 83 | 84 | /** Number of pistol rounds lost */ 85 | pistolRoundsLost: number; 86 | 87 | /** Pistol round win rate */ 88 | pistolRoundWinRate: number; 89 | 90 | /** Number of rounds where the player was the mvp */ 91 | mvpRounds: number; 92 | 93 | /** Mvp to round ratio */ 94 | mvpRate: number; 95 | 96 | /** Number of rounds by team */ 97 | roundsByTeam: { 98 | CT: number; 99 | T: number; 100 | }; 101 | 102 | /** Round win rate by team */ 103 | roundWinRateByTeam: { 104 | CT: number; 105 | T: number; 106 | }; 107 | 108 | /** Number of rounds by win type */ 109 | roundsByWinType: WinTypeStats; 110 | 111 | /** Number of rounds by enemies killed */ 112 | roundsByKills: number[]; 113 | 114 | /** Average equipment value per round */ 115 | avgEquipValue: number; 116 | 117 | /** Average money at the start of a round */ 118 | avgInitMoney: number; 119 | 120 | /** Average round duration in seconds */ 121 | avgRoundDuration: number; 122 | } 123 | 124 | export type Stats = MatchesStats & 125 | RoundsStats & { 126 | /** Average number of rounds per match */ 127 | avgRoundsPerMatch: number; 128 | 129 | /** Averge kills per round */ 130 | avgKillsPerRound: number; 131 | 132 | /** Percentage of rounds where the player died*/ 133 | roundDeathPercent: number; 134 | }; 135 | 136 | /** 137 | * Gets all the stats 138 | * @returns promise that resolves to an object containing the stats 139 | */ 140 | export function getAllStats(): Promise { 141 | return computeStats(Matches.getAll(), Rounds.getAll()); 142 | } 143 | 144 | /** 145 | * Computes stats from matches and rounds 146 | * @param matchesPromise promise that resolves to a list of matches 147 | * @param roundsPromise promise that resolves to a list of rounds 148 | * @returns promise that resolves to an object containing the stats 149 | */ 150 | function computeStats( 151 | matchesPromise: Promise, 152 | roundsPromise: Promise 153 | ): Promise { 154 | return new Promise((resolve, reject) => { 155 | Promise.all([ 156 | matchesPromise.then(computeStatsFromMatches), 157 | roundsPromise.then(computeStatsFromRounds), 158 | ]) 159 | .then(([matchesStats, roundsStats]) => { 160 | resolve({ 161 | ...matchesStats, 162 | ...roundsStats, 163 | avgKillsPerRound: 164 | roundsStats.totalRounds > 0 165 | ? matchesStats.kills / roundsStats.totalRounds 166 | : 0, 167 | roundDeathPercent: 168 | roundsStats.totalRounds > 0 169 | ? matchesStats.deaths / roundsStats.totalRounds 170 | : 0, 171 | avgRoundsPerMatch: 172 | matchesStats.totalMatches > 0 173 | ? roundsStats.totalRounds / matchesStats.totalMatches 174 | : 0, 175 | }); 176 | }) 177 | .catch((err) => { 178 | reject(err); 179 | }); 180 | }); 181 | } 182 | 183 | /** 184 | * Computes stats from a list of matches 185 | * @param list of matches 186 | * @return stats computed 187 | */ 188 | function computeStatsFromMatches(matches: Matches.Match[]): MatchesStats { 189 | let totalScore = 0; 190 | let totalMvps = 0; 191 | const matchesStats: MatchesStats = { 192 | kills: 0, 193 | killshs: 0, 194 | assists: 0, 195 | deaths: 0, 196 | kdr: 0, 197 | hsRate: 0, 198 | maxKillsPerMatch: 0, 199 | totalMatches: 0, 200 | matchesByResult: { 201 | victory: 0, 202 | defeat: 0, 203 | tie: 0, 204 | }, 205 | matchesByMap: {}, 206 | totalDuration: 0, 207 | winRate: 0, 208 | avgKillsPerMatch: 0, 209 | avgMatchDuration: 0, 210 | avgScore: 0, 211 | avgMvps: 0, 212 | }; 213 | 214 | matches.forEach((match) => { 215 | matchesStats.kills += match.kills; 216 | matchesStats.killshs += match.killshs; 217 | matchesStats.assists += match.assists; 218 | matchesStats.deaths += match.deaths; 219 | 220 | if (match.kills > matchesStats.maxKillsPerMatch) { 221 | matchesStats.maxKillsPerMatch = match.kills; 222 | } 223 | 224 | if (!matchesStats.matchesByMap[match.map]) { 225 | matchesStats.matchesByMap[match.map] = 1; 226 | } else matchesStats.matchesByMap[match.map] += 1; 227 | 228 | matchesStats.totalMatches += 1; 229 | if (match.roundsWon > match.roundsLost) { 230 | matchesStats.matchesByResult.victory += 1; 231 | } 232 | if (match.roundsWon < match.roundsLost) { 233 | matchesStats.matchesByResult.defeat += 1; 234 | } 235 | if (match.roundsWon === match.roundsLost) { 236 | matchesStats.matchesByResult.tie += 1; 237 | } 238 | 239 | totalScore += match.score; 240 | totalMvps += match.mvps; 241 | matchesStats.totalDuration += match.duration; 242 | }); 243 | 244 | if (matchesStats.totalMatches > 0) { 245 | matchesStats.winRate = 246 | matchesStats.matchesByResult.victory / matchesStats.totalMatches; 247 | matchesStats.avgKillsPerMatch = 248 | matchesStats.kills / matchesStats.totalMatches; 249 | matchesStats.avgMatchDuration = 250 | matchesStats.totalDuration / matchesStats.totalMatches; 251 | matchesStats.avgScore = totalScore / matchesStats.totalMatches; 252 | matchesStats.avgMvps = totalMvps / matchesStats.totalMatches; 253 | } 254 | 255 | matchesStats.kdr = 256 | matchesStats.kills / (matchesStats.deaths > 0 ? matchesStats.deaths : 1); 257 | matchesStats.hsRate = 258 | matchesStats.kills > 0 ? matchesStats.killshs / matchesStats.kills : 0; 259 | return matchesStats; 260 | } 261 | 262 | /** 263 | * Computes stats from a list of rounds 264 | * @param rounds list of rounds 265 | * @return stats computed 266 | */ 267 | function computeStatsFromRounds(rounds: Rounds.Round[]): RoundsStats { 268 | const roundsStats = { 269 | totalRounds: 0, 270 | totalRoundsWon: 0, 271 | totalRoundsLost: 0, 272 | roundWinRate: 0, 273 | pistolRoundsWon: 0, 274 | pistolRoundsLost: 0, 275 | pistolRoundWinRate: 0, 276 | mvpRounds: 0, 277 | mvpRate: 0, 278 | roundsByTeam: { 279 | CT: 0, 280 | T: 0, 281 | }, 282 | roundWinRateByTeam: { 283 | CT: 0, 284 | T: 0, 285 | }, 286 | roundsByWinType: { 287 | ct_win_elimination: 0, 288 | ct_win_defuse: 0, 289 | ct_win_time: 0, 290 | t_win_elimination: 0, 291 | t_win_bomb: 0, 292 | }, 293 | roundsByKills: [0, 0, 0, 0, 0, 0], 294 | avgEquipValue: 0, 295 | avgInitMoney: 0, 296 | avgRoundDuration: 0, 297 | }; 298 | let totalEquipValue = 0; 299 | let totalInitMoney = 0; 300 | let totalRoundDuration = 0; 301 | let roundsWonByTeam = { 302 | CT: 0, 303 | T: 0, 304 | }; 305 | 306 | rounds.forEach((round) => { 307 | roundsStats.totalRounds += 1; 308 | 309 | if (round.winner === round.team) { 310 | roundsStats.totalRoundsWon += 1; 311 | roundsWonByTeam[round.team as 'CT' | 'T'] += 1; 312 | if (round.n === 1 || round.n === 16) roundsStats.pistolRoundsWon += 1; 313 | } else { 314 | roundsStats.totalRoundsLost += 1; 315 | if (round.n === 1 || round.n === 16) roundsStats.pistolRoundsLost += 1; 316 | } 317 | 318 | if (round.mvp) roundsStats.mvpRounds += 1; 319 | 320 | roundsStats.roundsByTeam[round.team as 'CT' | 'T'] += 1; 321 | if (round.winType) { 322 | roundsStats.roundsByWinType[round.winType as keyof WinTypeStats] += 1; 323 | } 324 | 325 | totalEquipValue += round.equipValue; 326 | totalInitMoney += round.initMoney; 327 | totalRoundDuration += round.duration; 328 | roundsStats.roundsByKills[round.kills > 0 ? round.kills : 0] += 1; 329 | }); 330 | 331 | if (roundsStats.totalRounds > 0) { 332 | roundsStats.roundWinRate = 333 | roundsStats.totalRoundsWon / roundsStats.totalRounds; 334 | roundsStats.mvpRate = roundsStats.mvpRounds / roundsStats.totalRoundsWon; 335 | roundsStats.avgEquipValue = totalEquipValue / roundsStats.totalRounds; 336 | roundsStats.avgInitMoney = totalInitMoney / roundsStats.totalRounds; 337 | roundsStats.avgRoundDuration = totalRoundDuration / roundsStats.totalRounds; 338 | (['CT', 'T'] as Array<'CT' | 'T'>).map((team) => { 339 | roundsStats.roundWinRateByTeam[team] = 340 | roundsWonByTeam[team] / 341 | roundsStats.roundsByTeam[team]; 342 | }); 343 | } 344 | 345 | return { 346 | ...roundsStats, 347 | pistolRoundWinRate: 348 | roundsStats.pistolRoundsLost + roundsStats.pistolRoundsWon > 0 349 | ? roundsStats.pistolRoundsWon / 350 | (roundsStats.pistolRoundsWon + roundsStats.pistolRoundsLost) 351 | : 0, 352 | }; 353 | } 354 | -------------------------------------------------------------------------------- /restapi/statsCache.ts: -------------------------------------------------------------------------------- 1 | import { Stats } from './services/statsService'; 2 | 3 | let statsCache = new Map(); 4 | 5 | /** 6 | * Adds a stats object to the cache 7 | * @param key key identifying the stats object 8 | * @param stats stats object 9 | */ 10 | export function set(key: string, stats: Stats) { 11 | statsCache.set(key, stats); 12 | } 13 | 14 | /** 15 | * Retrieves a stats object from the cache 16 | * @param key key identifying the stats object 17 | * @returns stats object or undefined if it cant be found 18 | */ 19 | export function get(key: string): Stats | undefined { 20 | return statsCache.get(key); 21 | } 22 | 23 | /** 24 | * Checks if the caches has a key 25 | * @param key key identifying a stats object 26 | * @returns true if the caches has the key, false if not 27 | */ 28 | export function has(key: string): boolean { 29 | return statsCache.has(key); 30 | } 31 | 32 | /** 33 | * Invalidates the whole cache 34 | */ 35 | export function invalidate() { 36 | statsCache.clear(); 37 | } 38 | -------------------------------------------------------------------------------- /restapi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node 16", 4 | 5 | "compilerOptions": { 6 | "lib": ["es2021"], 7 | "module": "commonjs", 8 | "target": "es2021", 9 | 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "outDir": "dist" 15 | }, 16 | 17 | "exclude": ["dist"] 18 | } 19 | -------------------------------------------------------------------------------- /screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/screenshot1.png -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/screenshot2.png -------------------------------------------------------------------------------- /screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/screenshot3.png -------------------------------------------------------------------------------- /webapp/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "vueIndentScriptAndStyle": true 5 | } 6 | -------------------------------------------------------------------------------- /webapp/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import pluginVue from 'eslint-plugin-vue' 3 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | 5 | export default [ 6 | { 7 | name: 'app/files-to-lint', 8 | files: ['**/*.{js,mjs,jsx,vue}'], 9 | }, 10 | 11 | { 12 | name: 'app/files-to-ignore', 13 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 14 | }, 15 | 16 | js.configs.recommended, 17 | ...pluginVue.configs['flat/essential'], 18 | skipFormatting, 19 | ] 20 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | csgo-tracker 14 | 15 | 16 | 17 | 18 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "version": "0.2.0", 4 | "private": true, 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "bootstrap": "^5.3.3", 14 | "chart.js": "^4.4.6", 15 | "vue": "^3.5.12", 16 | "vue-router": "^4.4.5" 17 | }, 18 | "devDependencies": { 19 | "@eslint/js": "^9.13.0", 20 | "@vitejs/plugin-vue": "^5.1.4", 21 | "@vue/eslint-config-prettier": "^10.0.0", 22 | "eslint": "^9.13.0", 23 | "eslint-plugin-vue": "^9.29.0", 24 | "vite": "^5.4.10" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /webapp/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/favicon.ico -------------------------------------------------------------------------------- /webapp/public/img/death.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/death.png -------------------------------------------------------------------------------- /webapp/public/img/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/github-logo.png -------------------------------------------------------------------------------- /webapp/public/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 36 | 41 | 42 | 44 | 47 | 51 | 55 | 56 | 67 | 70 | 74 | 75 | 86 | 97 | 108 | 119 | 130 | 141 | 152 | 163 | 174 | 185 | 196 | 207 | 218 | 229 | 240 | 251 | 252 | 257 | 264 | 271 | 278 | 286 | 287 | 288 | -------------------------------------------------------------------------------- /webapp/public/img/kill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/kill.png -------------------------------------------------------------------------------- /webapp/public/img/killhs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/killhs.png -------------------------------------------------------------------------------- /webapp/public/img/map-icons/map_icon_cs_militia.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 51 | 52 | 53 | 54 | 55 | 66 | 67 | 68 | 69 | 70 | 73 | 74 | 75 | MILITIA 76 | 77 | 78 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /webapp/public/img/map-icons/map_icon_de_bank.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | 35 | 39 | 43 | 45 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | 61 | 62 | 63 | 71 | 77 | 82 | 83 | 84 | 90 | 91 | 92 | 95 | 97 | 99 | 100 | 101 | 111 | 112 | 113 | 114 | 115 | 125 | 126 | 127 | 128 | 129 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /webapp/public/img/map-icons/map_icon_de_lake.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 61 | 62 | 63 | 71 | 76 | 77 | 78 | 84 | 85 | 86 | 152 | 153 | 155 | 157 | 158 | 159 | 163 | 164 | 165 | 166 | 167 | 177 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /webapp/public/img/map-icons/map_icon_de_train.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 60 | 65 | 66 | 67 | 68 | 69 | 74 | 75 | 76 | 77 | 78 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 92 | 94 | 96 | 97 | 98 | 103 | 105 | 106 | 108 | 109 | 110 | 111 | 116 | 118 | 119 | 121 | 122 | 123 | 124 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /webapp/public/img/map-icons/map_icon_dz_blacksite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 83 | 84 | 85 | 86 | 87 | 88 | 90 | 92 | 93 | 94 | 95 | 96 | 100 | 101 | 102 | 103 | 104 | 105 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 121 | 130 | 137 | 138 | 145 | 146 | 148 | 153 | 155 | 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/ar_baggage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/ar_baggage.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/ar_dizzy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/ar_dizzy.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/ar_monastery.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/ar_monastery.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/ar_shoots.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/ar_shoots.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/cs_agency.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/cs_agency.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/cs_assault.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/cs_assault.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/cs_italy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/cs_italy.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/cs_militia.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/cs_militia.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/cs_office.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/cs_office.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_ancient.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_ancient.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_bank.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_bank.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_cache.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_cache.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_canals.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_canals.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_cbble.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_cbble.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_dust2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_dust2.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_inferno.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_inferno.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_lake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_lake.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_mirage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_mirage.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_nuke.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_nuke.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_overpass.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_overpass.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_safehouse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_safehouse.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_shortnuke.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_shortnuke.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_stmarc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_stmarc.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_sugarcane.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_sugarcane.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_train.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_train.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/de_vertigo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/de_vertigo.jpg -------------------------------------------------------------------------------- /webapp/public/img/maps-opaque/gd_cbble.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidaf3/csgo-tracker/4318ce78e3da7bf4e12f474ce3ac7d8508856627/webapp/public/img/maps-opaque/gd_cbble.jpg -------------------------------------------------------------------------------- /webapp/public/img/match-in-progress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /webapp/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 68 | -------------------------------------------------------------------------------- /webapp/src/api/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Match 3 | * @property {string} id id of the match 4 | * @property {string} playerId id of the player 5 | * @property {string} map name of the map 6 | * @property {string} mode name of the gamemode 7 | * @property {Date} date date of the match 8 | * @property {number} duration duration in seconds 9 | * @property {number} roundsWon number of rounds won 10 | * @property {number} roundsLost number of rounds lost 11 | * @property {number} kills number of kills 12 | * @property {number} killshs number of kills by headshot 13 | * @property {number} assists number of assists 14 | * @property {number} deaths number of deaths 15 | * @property {number} mvps number of mvps won 16 | * @property {number} score score of the player 17 | * @property {boolean} over wheter the match is over or not 18 | */ 19 | 20 | /** 21 | * Gets all the matches 22 | * @return {Promise} match list 23 | */ 24 | export async function getMatches() { 25 | try { 26 | const response = await fetch('http://localhost:8090/match', { 27 | method: 'GET', 28 | }); 29 | return await response.json(); 30 | } catch (err) { 31 | return []; 32 | } 33 | } 34 | 35 | /** 36 | * Gets a match 37 | * @param {string} id match id 38 | * @return {Promise} match 39 | */ 40 | export async function getMatch(id) { 41 | try { 42 | const response = await fetch(`http://localhost:8090/match/${id}`, { 43 | method: 'GET', 44 | }); 45 | return await response.json(); 46 | } catch (err) { 47 | return null; 48 | } 49 | } 50 | 51 | /** 52 | * Deletes a match 53 | * @param {string} id match id 54 | */ 55 | export function deleteMatch(id) { 56 | fetch(`http://localhost:8090/match/${id}`, { 57 | method: 'DELETE', 58 | }); 59 | } 60 | 61 | /** 62 | * Forces a match to end 63 | * @param {string} id match id 64 | * @return {Promise} finished match or null if 65 | * no such match was found 66 | */ 67 | export async function forceMatchEnd(id) { 68 | try { 69 | const response = await fetch(`http://localhost:8090/match/${id}/forceEnd`, { 70 | method: 'POST', 71 | }); 72 | return await response.json(); 73 | } catch (err) { 74 | return null; 75 | } 76 | } 77 | 78 | /** 79 | * @typedef {Object} Round 80 | * @property {string} id id of the round 81 | * @property {string} matchId id of the match 82 | * @property {number} n number of the round 83 | * @property {string} team team of the player 84 | * @property {number} equipValue value of the equipment 85 | * @property {number} initMoney money at the beginning of the round 86 | * @property {number} initArmor armor at the beginning of the round 87 | * @property {boolean} helmet whether the player has helmet on 88 | * @property {number} duration duration of the round in seconds 89 | * @property {string} winner winner team 90 | * @property {string} winType string indicating the result of the round 91 | * @property {boolean} died whether the player died 92 | * @property {number} kills number of kills 93 | * @property {number} killshs number of kills by headshot 94 | * @property {number} assists number of assists 95 | * @property {number} score score of the player 96 | * @property {boolean} mvp whether the player was the mvp 97 | */ 98 | 99 | /** 100 | * Gets all the rounds of a match 101 | * @param {string} matchId id of the match 102 | * @returns {Promise} list of rounds 103 | */ 104 | export async function getRounds(matchId) { 105 | try { 106 | const response = await fetch( 107 | `http://localhost:8090/match/${matchId}/round`, 108 | { 109 | method: 'GET', 110 | } 111 | ); 112 | return await response.json(); 113 | } catch (err) { 114 | return []; 115 | } 116 | } 117 | 118 | /** 119 | * @typedef {Object} Stats 120 | * @property {number} kills total kills 121 | * @property {number} killshs total headshot kills 122 | * @property {number} assists total assists 123 | * @property {number} deaths total deaths 124 | * @property {number} kdr kill to death ratio 125 | * @property {number} hsRate headshot kill ratio 126 | * @property {number} maxKillsPerMatch max kills in a single match 127 | * @property {number} avgKillsPerMatch average kills per match 128 | * @property {number} totalMatches total number of matches 129 | * @property {object} matchesByMap number of matches by map 130 | * @property {{ 131 | * victory: number, 132 | * defeat: number, 133 | * tie: number 134 | * }} matchesByResult total number of matches by result 135 | * @property {number} winRate match win rate 136 | * @property {number} totalDuration total number of seconds played 137 | * @property {number} avgMatchDuration average match duration in seconds 138 | * @property {number} avgScore average match score 139 | * @property {number} avgMvps average mvps per match 140 | * @property {number} totalRounds total number of rounds 141 | * @property {number} totalRoundsWon total number of rounds won 142 | * @property {number} totalRoundsLost total number of rounds lost 143 | * @property {number} roundWinRate round win rate 144 | * @property {number} pistolRoundsWon number of pistol rounds won 145 | * @property {number} pistolRoundsLost number of pistol rounds lost 146 | * @property {number} pistolRoundWinRate pistol round win rate 147 | * @property {number} mvpRounds number of rounds where the player was the mvp 148 | * @property {number} mvpRate mvp to round ratio 149 | * @property {{ 150 | * CT: number, 151 | * T: number, 152 | * }} roundsByTeam number of rounds by team 153 | * @property {{ 154 | * ct_win_elimination: number, 155 | * ct_win_defuse: number, 156 | * ct_win_time: number, 157 | * t_win_elimination: number, 158 | * t_win_bomb: number 159 | * }} roundsByWinType number of rounds by win type 160 | * @property {number[]} roundsByKills number of rounds by enemies killed 161 | * @property {number} avgEquipValue average equipment value per round 162 | * @property {number} avgInitMoney average money at the start of a round 163 | * @property {number} avgRoundDuration average round duration in seconds 164 | * @property {number} avgRoundsPerMatch average number of rounds per match 165 | */ 166 | 167 | /** 168 | * Gets all the stats 169 | * @return {Promise} object containing the stats 170 | */ 171 | export async function getAllStats() { 172 | try { 173 | const response = await fetch('http://localhost:8090/stats', { 174 | method: 'GET', 175 | }); 176 | return await response.json(); 177 | } catch (err) { 178 | return null; 179 | } 180 | } 181 | 182 | /** 183 | * Gets info about a player 184 | * @param {string} steamId id of the player 185 | * @returns {Object} info about the player 186 | */ 187 | export async function getPlayerInfo(steamId) { 188 | try { 189 | const response = await fetch(`http://localhost:8090/player/${steamId}`, { 190 | method: 'GET', 191 | }); 192 | return await response.json(); 193 | } catch (err) { 194 | return {}; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /webapp/src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 59 | -------------------------------------------------------------------------------- /webapp/src/components/match/MatchDetails.vue: -------------------------------------------------------------------------------- 1 | 138 | 139 | 223 | 224 | 306 | -------------------------------------------------------------------------------- /webapp/src/components/match/MatchPreview.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 97 | 98 | 190 | -------------------------------------------------------------------------------- /webapp/src/components/match/MatchesView.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 114 | 115 | 134 | -------------------------------------------------------------------------------- /webapp/src/components/match/MoneyChart.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 161 | 162 | 188 | -------------------------------------------------------------------------------- /webapp/src/components/match/RoundsChart.vue: -------------------------------------------------------------------------------- 1 | 145 | 146 | 231 | 232 | 251 | -------------------------------------------------------------------------------- /webapp/src/components/stats/BarChart.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 222 | 223 | 254 | -------------------------------------------------------------------------------- /webapp/src/components/stats/DonutChart.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 253 | 254 | 330 | -------------------------------------------------------------------------------- /webapp/src/components/stats/StatsDashboard.vue: -------------------------------------------------------------------------------- 1 | 211 | 212 | 278 | 279 | 326 | -------------------------------------------------------------------------------- /webapp/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import 'bootstrap/dist/css/bootstrap.min.css'; 3 | import * as VueRouter from 'vue-router'; 4 | import App from './App.vue'; 5 | import MatchesView from './components/match/MatchesView.vue'; 6 | import StatsDashboard from './components/stats/StatsDashboard.vue'; 7 | 8 | const router = VueRouter.createRouter({ 9 | history: VueRouter.createWebHashHistory(), 10 | routes: [ 11 | { path: '/', component: MatchesView }, 12 | { path: '/matches', component: MatchesView }, 13 | { path: '/stats', component: StatsDashboard }, 14 | ], 15 | }); 16 | 17 | createApp(App) 18 | .use(router) 19 | .mount('#app'); 20 | -------------------------------------------------------------------------------- /webapp/src/util/palette.js: -------------------------------------------------------------------------------- 1 | const palettes = { 2 | basic: { 3 | colors: [ 4 | 'rgb(105, 41, 196)', 5 | 'rgb(17, 146, 232)', 6 | 'rgb(0, 93, 93)', 7 | 'rgb(159, 24, 83)', 8 | 'rgb(250, 77, 86)', 9 | 'rgb(87, 4, 8)', 10 | 'rgb(25, 128, 56)', 11 | 'rgb(0, 45, 156)', 12 | 'rgb(238, 83, 139)', 13 | 'rgb(178, 134, 0)', 14 | 'rgb(0, 157, 154)', 15 | 'rgb(1, 39, 73)', 16 | 'rgb(138, 56, 0)', 17 | 'rgb(165, 110, 255)', 18 | ], 19 | darkColors: [ 20 | 'rgba(105, 41, 196, 0.2)', 21 | 'rgba(17, 146, 232, 0.2)', 22 | 'rgba(0, 93, 93, 0.2)', 23 | 'rgba(159, 24, 83, 0.2)', 24 | 'rgba(250, 77, 86, 0.2)', 25 | 'rgba(87, 4, 8, 0.2)', 26 | 'rgba(25, 128, 56, 0.2)', 27 | 'rgba(0, 45, 156, 0.2)', 28 | 'rgba(238, 83, 139, 0.2)', 29 | 'rgba(178, 134, 0, 0.2)', 30 | 'rgba(0, 157, 154, 0.2)', 31 | 'rgba(1, 39, 73, 0.2)', 32 | 'rgba(138, 56, 0, 0.2)', 33 | 'rgba(165, 110, 255, 0.2)', 34 | ], 35 | }, 36 | results: { 37 | colors: ['rgb(25, 135, 84)', 'rgb(108, 117, 125)', 'rgb(220, 53, 69)'], 38 | darkColors: [ 39 | 'rgba(25, 135, 84, 0.2)', 40 | 'rgba(108, 117, 125, 0.2)', 41 | 'rgba(220, 53, 69, 0.2)', 42 | ], 43 | }, 44 | resultsWithoutTie: { 45 | colors: ['rgb(25, 135, 84)', 'rgb(220, 53, 69)'], 46 | darkColors: ['rgba(25, 135, 84, 0.2)', 'rgba(220, 53, 69, 0.2)'], 47 | }, 48 | teams: { 49 | colors: ['rgb(93,121,174)', 'rgb(222,155,53)'], 50 | darkColors: ['rgba(93,121,174, 0.2)', 'rgba(222,155,53, 0.2)'], 51 | }, 52 | }; 53 | 54 | export default palettes; 55 | -------------------------------------------------------------------------------- /webapp/src/util/popover.js: -------------------------------------------------------------------------------- 1 | import { Popover } from 'bootstrap'; 2 | 3 | /** 4 | * Create popovers for all descendants of an element 5 | * @param {string} identifier id of the parent element 6 | * @param {(previousSibling: HTMLElement) => void} previousSiblingOnMouseEnter function 7 | * to be called when the mouse enters the popover trigger's previous sibling 8 | * @param {(previousSibling: HTMLElement) => void} previousSiblingOnMouseLeave function 9 | * to be called when the mouse leaves the popover trigger's previous sibling 10 | */ 11 | export default function createPopovers( 12 | identifier, 13 | previousSiblingOnMouseEnter, 14 | previousSiblingOnMouseLeave 15 | ) { 16 | const popoverTriggerList = document.querySelectorAll( 17 | `#${identifier} [data-bs-toggle="popover"]` 18 | ); 19 | const popovers = [...popoverTriggerList].map( 20 | (popoverTrigger) => 21 | new Popover(popoverTrigger, { 22 | container: 'body', 23 | html: true, 24 | sanitize: false, 25 | }) 26 | ); 27 | 28 | popoverTriggerList.forEach((popoverTrigger, i) => { 29 | const previousSibling = popoverTrigger.previousElementSibling; 30 | let leaveTimeout = null; 31 | let shown = false; 32 | 33 | popoverTrigger.addEventListener('hidden.bs.popover', () => { 34 | shown = false; 35 | }); 36 | 37 | previousSibling.onmouseenter = () => { 38 | previousSiblingOnMouseEnter(previousSibling); 39 | if (!shown) { 40 | shown = true; 41 | popovers[i].show(); 42 | } else clearTimeout(leaveTimeout); 43 | }; 44 | 45 | previousSibling.onmouseleave = () => { 46 | previousSiblingOnMouseLeave(previousSibling); 47 | leaveTimeout = setTimeout(() => { 48 | popovers[i].hide(); 49 | }, 500); 50 | }; 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /webapp/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | } 16 | }) 17 | --------------------------------------------------------------------------------