├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── client
├── .gitignore
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── index.html
│ ├── logo256.png
│ ├── logo512.png
│ ├── mstile-150x150.png
│ ├── robots.txt
│ ├── safari-pinned-tab.svg
│ ├── site.webmanifest
│ └── sitemap.xml
├── src
│ ├── App.tsx
│ ├── ThemeProvider.tsx
│ ├── components
│ │ ├── country
│ │ │ ├── CountriesTable.tsx
│ │ │ ├── CountryContributors.tsx
│ │ │ ├── CountryDetails.tsx
│ │ │ ├── CountryGraphs.tsx
│ │ │ └── CountryPlayers.tsx
│ │ ├── graphs
│ │ │ ├── CompareGraph.tsx
│ │ │ ├── CountryPlayersGraph.tsx
│ │ │ ├── CustomResponsiveContainer.tsx
│ │ │ ├── GraphDropdown.tsx
│ │ │ ├── TimeScatterGraph.tsx
│ │ │ ├── TimeSeriesChart.tsx
│ │ │ └── util.ts
│ │ ├── misc
│ │ │ ├── ChangeLog.tsx
│ │ │ ├── SimpleSummaryAccordion.tsx
│ │ │ ├── TopPlays.tsx
│ │ │ └── TopPlaysDatePicker.tsx
│ │ ├── navigation
│ │ │ ├── DarkMode.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── HomeLink.tsx
│ │ │ ├── Menu.tsx
│ │ │ ├── Search.tsx
│ │ │ └── SearchResults.tsx
│ │ ├── pages
│ │ │ ├── All.tsx
│ │ │ ├── Compare.tsx
│ │ │ ├── Country.tsx
│ │ │ ├── Historic.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── Loading.tsx
│ │ │ ├── Redirect.tsx
│ │ │ ├── Stats.tsx
│ │ │ └── User.tsx
│ │ ├── stats
│ │ │ ├── PPBarriers.tsx
│ │ │ ├── TopMapsets.tsx
│ │ │ └── TopPlay.tsx
│ │ └── user
│ │ │ ├── UserDetails.tsx
│ │ │ ├── UserGraphs.tsx
│ │ │ └── UsersTable.tsx
│ ├── index.scss
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ ├── resources
│ │ ├── changelog.json
│ │ └── collection-helper.png
│ └── util
│ │ ├── parseUrl.ts
│ │ └── selectTheme.ts
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
├── interfaces
├── country.ts
├── search.ts
└── stats.ts
├── models
├── Beatmap.model.ts
├── BeatmapCount.model.ts
├── BeatmapSetCount.model.ts
├── Country.model.ts
├── CountryPlayers.model.ts
├── CountryPlays.model.ts
├── CountryStat.model.ts
├── HistoricTop.model.ts
├── OverallStats.model.ts
├── PPBarrier.model.ts
├── Score.ts
├── TopPlayCount.model.ts
├── User.model.ts
├── UserPlays.model.ts
└── UserStat.model.ts
├── package-lock.json
├── package.json
├── routes
├── countries.ts
├── search.ts
├── stats.ts
└── users.ts
└── server.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | client/node_modules
4 | client/build
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | client/node_modules
4 | client/build
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14
2 | WORKDIR /usr/src/app
3 | COPY package*.json .
4 | RUN npm install
5 | COPY . .
6 | RUN cd client && npm install
7 | RUN cd client && npm run build
8 | EXPOSE 8080
9 | CMD ["npm", "run", "start"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | [osuTracker.com](https://osutracker.com)
7 |
8 |
9 |
16 |
17 |
18 |
19 |
20 | [](https://github.com/nzbasic/osutracker)
21 | [](https://github.com/nzbasic/osutracker)
22 | [](https://github.com/nzbasic/osutracker)
23 |
24 |
25 | [](https://github.com/microsoft/TypeScript)
26 | [](https://github.com/facebook/react)
27 | [](https://github.com/mongodb/mongo)
28 | [](https://github.com/tailwindlabs/tailwindcss)
29 | [](https://github.com/nodejs/node)
30 |
31 |
32 | [](https://www.buymeacoffee.com/nzbasic)
33 |
34 |
35 |
36 |
37 | Table of Contents
38 |
39 | - [About](#about)
40 | - [Screenshots](#screenshots)
41 | - [Contributing](#contributing)
42 | - [Support](#support)
43 | - [Donate](#donate)
44 |
45 |
46 |
47 | ---
48 |
49 | ## About
50 |
51 |
52 |
53 |
54 |
55 | osuTracker is a tool that tracks osu! statistics of any player who signs up. It also combines the players of every country to give custom country statistics such as the top 100 play history and top 10 players over time. Data is collected once a day; when it is collected, the most common beatmaps seen in profiles are counted up to find the most popular mapsets in the game which is used to track the farm percentage of each player. The API for osuTracker is public and has [full documentation](https://wiki.nzbasic.com/docs/osuTracker/aboutOsuTracker).
56 |
57 | The key features of **osuTracker**:
58 |
59 | - Tracking user stats
60 | - Tracking country stats
61 | - Allows any user to add themselves automatically
62 | - Tracking history of top 100 plays for each country (and global)
63 | - Tracking history of top 10 plays for each user
64 | - Compare user/country stats
65 | - Public API for devs
66 | - Optimised for mobile and desktop views
67 |
68 |
69 |
70 |
71 |
72 | ### Screenshots
73 |
74 | Home
75 | 
76 |
77 | User Profile: [Link](https://osutracker.com/user/7562902)
78 | 
79 |
80 | Comparing Users: [Link](https://osutracker.com/compare?one=13767572&two=15293080&three=6934358&four=13192231&)
81 | 
82 |
83 | Dark Mode:
84 | 
85 |
86 | ## Contributing
87 |
88 | First off, thanks for taking the time to contribute! Contributions are what makes the open-source community such an amazing place to learn, inspire, and create. Any contributions you make will benefit everybody else and are **greatly appreciated**.
89 |
90 | Please try to create bug reports that are:
91 |
92 | - _Reproducible._ Include steps to reproduce the problem.
93 | - _Specific._ Include as much detail as possible: which version, what environment, etc.
94 | - _Unique._ Do not duplicate existing opened issues.
95 | - _Scoped to a Single Bug._ One bug per report.
96 |
97 | ## Support
98 |
99 | Reach out to the maintainer at one of the following places:
100 |
101 | - Discord: basic#7373
102 | - Twitter: @nzbasic
103 | - osu!: YEP
104 | - Email: jamescoppard024@gmail.com
105 |
106 | ## Donate
107 |
108 | If you would like to support me I would greatly appreciate it.
109 |
110 | [](https://www.buymeacoffee.com/nzbasic)
111 |
112 | Crypto
113 | - NANO: nano_3ymx5ymxgwrsfc53mem7bfmwjgzwxhtzp41wdkepnxmjdzzhhf3dgiiif8qc
114 | - ETH: 0x46cB2b27C5607282BAdAaf9973EFd728D202A1d3
115 | - BTC: bc1q0f0xtmmf7n05qgnmeun6ytc8z676j8tryszrr3
116 | - DOGE: DRRhYtaFFoyGUaU1h8MyE8LBbMETjDU5AR
117 |
118 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `yarn test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `yarn build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `yarn eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/client/craco.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | style: {
3 | postcss: {
4 | plugins: [require("tailwindcss"), require("autoprefixer")],
5 | },
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "proxy": "http://localhost:8080",
6 | "dependencies": {
7 | "@craco/craco": "^6.3.0",
8 | "@emotion/react": "^11.5.0",
9 | "@emotion/styled": "^11.3.0",
10 | "@material-ui/core": "^4.12.3",
11 | "@material-ui/icons": "^4.11.2",
12 | "@mui/material": "^5.0.6",
13 | "@testing-library/jest-dom": "^5.11.4",
14 | "@testing-library/react": "^11.1.0",
15 | "@testing-library/user-event": "^12.1.10",
16 | "@types/cors": "^2.8.12",
17 | "@types/jest": "^26.0.15",
18 | "@types/lodash": "^4.14.176",
19 | "@types/node": "^12.0.0",
20 | "@types/react": "^17.0.32",
21 | "@types/react-datepicker": "^4.3.4",
22 | "@types/react-dom": "^17.0.0",
23 | "@types/react-helmet": "^6.1.4",
24 | "@types/react-router-dom": "^5.3.1",
25 | "@types/recharts": "^1.8.23",
26 | "@types/uuid": "^8.3.1",
27 | "axios": "^0.23.0",
28 | "hamburger-react": "^2.4.1",
29 | "history": "^5.0.1",
30 | "lodash": "^4.17.21",
31 | "moment": "^2.29.1",
32 | "node-sass": "^5.0.0",
33 | "query-string": "^7.0.1",
34 | "react": "^17.0.2",
35 | "react-animated-css": "^1.2.1",
36 | "react-contextmenu": "^2.14.0",
37 | "react-datepicker": "^4.5.0",
38 | "react-dom": "^17.0.2",
39 | "react-helmet": "^6.1.0",
40 | "react-router-dom": "^6.0.0-beta.8",
41 | "react-scripts": "4.0.3",
42 | "react-select": "^5.1.0",
43 | "react-timezone-select": "^1.1.15",
44 | "react-toastify": "^8.0.3",
45 | "recharts": "^2.1.5",
46 | "string-to-color": "^2.2.2",
47 | "typescript": "^4.1.2",
48 | "use-debounce": "^7.0.0",
49 | "uuid": "^8.3.2",
50 | "web-vitals": "^1.0.1"
51 | },
52 | "scripts": {
53 | "start": "craco start",
54 | "build": "craco build",
55 | "test": "craco test",
56 | "eject": "react-scripts eject"
57 | },
58 | "eslintConfig": {
59 | "extends": [
60 | "react-app",
61 | "react-app/jest"
62 | ]
63 | },
64 | "browserslist": {
65 | "production": [
66 | ">0.2%",
67 | "not dead",
68 | "not op_mini all"
69 | ],
70 | "development": [
71 | "last 1 chrome version",
72 | "last 1 firefox version",
73 | "last 1 safari version"
74 | ]
75 | },
76 | "devDependencies": {
77 | "autoprefixer": "^9.8.8",
78 | "postcss": "^7.0.39",
79 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/client/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/client/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/client/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/client/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/client/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/favicon-16x16.png
--------------------------------------------------------------------------------
/client/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/favicon-32x32.png
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
33 | osuTracker
34 |
35 |
36 | You need to enable JavaScript to run this app.
37 |
38 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/client/public/logo256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/logo256.png
--------------------------------------------------------------------------------
/client/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/logo512.png
--------------------------------------------------------------------------------
/client/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/public/mstile-150x150.png
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/client/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/client/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/client/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | https://osutracker.com/
10 |
11 |
12 | https://osutracker.com/historic
13 |
14 |
15 | https://osutracker.com/compare
16 |
17 |
18 | https://osutracker.com/stats
19 |
20 |
21 | https://osutracker.com/country/Global
22 |
23 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter, Route, Routes } from "react-router-dom";
2 | import { Footer } from "./components/navigation/Footer";
3 | import { Header } from "./components/navigation/Header";
4 | import { All } from "./components/pages/All";
5 | import { Compare } from "./components/pages/Compare";
6 | import { Country } from "./components/pages/Country";
7 | import { Historic } from "./components/pages/Historic";
8 | import { Home } from "./components/pages/Home";
9 | import { Redirect } from "./components/pages/Redirect";
10 | import { Stats } from "./components/pages/Stats";
11 | import { User } from "./components/pages/User";
12 | import ThemeProvider from "./ThemeProvider";
13 |
14 | export const App = () => {
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 | } />
24 | } />
25 | } />
26 | } />
27 | } />
28 | } />
29 | } />
30 | } />
31 | } />
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/client/src/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, createContext, useEffect } from "react";
2 |
3 | export interface Theme {
4 | mode: string,
5 | toggle: () => void
6 | }
7 |
8 | export const ThemeContext = createContext(null);
9 |
10 | const ThemeProvider = ({ children }: any) => {
11 | const [mode, setTheme] = useState("light");
12 |
13 | useEffect(() => {
14 | if (localStorage.getItem("theme") === "dark") {
15 | document.documentElement.classList.add("dark");
16 | setTheme("dark");
17 | }
18 | }, []);
19 |
20 | const toggleDarkMode = () => {
21 | const doc = document.documentElement;
22 |
23 | if (mode === "light") {
24 | doc.classList.add("dark");
25 | setTheme("dark")
26 | localStorage.setItem("theme", "dark");
27 | } else {
28 | doc.classList.remove("dark");
29 | setTheme("light")
30 | localStorage.setItem("theme", "light");
31 | }
32 | };
33 |
34 | return (
35 | toggleDarkMode()
39 | }}
40 | >
41 | {children}
42 |
43 | );
44 | };
45 |
46 | export default ThemeProvider;
--------------------------------------------------------------------------------
/client/src/components/country/CountriesTable.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useEffect, useState } from "react";
3 | import { Country } from "../../../../models/Country.model";
4 |
5 | interface Header {
6 | name: string
7 | value: string
8 | mobile?: boolean
9 | }
10 |
11 | const headers: Header[] = [
12 | { name: "Rank", value: "rank", mobile: true },
13 | { name: "Name", value: "name", mobile: true },
14 | { name: "pp (Plays)", value: "pp", mobile: true },
15 | { name: "pp (Players)", value: "playerWeighting", mobile: true },
16 | { name: "Acc", value: "acc", mobile: true },
17 | { name: "Farm", value: "farm" },
18 | { name: "Range", value: "range" },
19 | { name: "Objects/Play", value: "averageObjects" }
20 | ]
21 |
22 | export const CountriesTable = () => {
23 | const [data, setData] = useState([]);
24 | const [sorting, setSorting] = useState("pp")
25 | const [order, setOrder] = useState("desc")
26 |
27 | useEffect(() => {
28 | document.title = "All Countries"
29 | axios
30 | .get("/api/countries/limitedAll")
31 | .then((data) => {
32 | const countries = data.data.sort((a, b) => parseFloat(b.pp) - parseFloat(a.pp))
33 | countries.forEach((country, index) => {
34 | country.rank = index
35 | })
36 | setData(countries)
37 | });
38 | }, [])
39 |
40 | useEffect(() => {
41 | if (order === "asc") {
42 | setData(data.sort((a: any, b: any) => parseFloat(a[sorting]) - parseFloat(b[sorting])))
43 | } else {
44 | setData(data.sort((a: any, b: any) => parseFloat(b[sorting]) - parseFloat(a[sorting])))
45 | }
46 | }, [sorting, order, data])
47 |
48 | const parseSorting = (value: string) => {
49 | if (value === sorting) {
50 | if (order === "desc") {
51 | setOrder("asc")
52 | } else {
53 | setOrder("desc")
54 | }
55 | } else {
56 | setSorting(value)
57 | }
58 | }
59 |
60 | return (
61 |
62 |
63 |
64 |
65 | {headers.map((header) => (
66 | parseSorting(header.value)} className={`${!header.mobile && 'hidden md:table-cell'} hover:underline cursor-pointer`} key={header.name}>{header.name}
67 | ))}
68 |
69 |
70 | {data.map((d, index) => (
71 |
72 |
73 | {d?.rank??0}
74 | {d.name}
75 | {parseFloat(d.pp).toFixed(0)}
76 | {(d?.playerWeighting??0).toFixed(0)}
77 | {(d.acc*100).toFixed(2)}%
78 | {d.farm}%
79 | {parseFloat(d.range).toFixed(0)}
80 | {d.averageObjects.toFixed(0)}
81 |
82 |
83 | ))}
84 |
85 |
86 | )
87 | }
--------------------------------------------------------------------------------
/client/src/components/country/CountryContributors.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { Contributor } from "../../../../models/Country.model";
3 |
4 | export const CountryContributors = ({ contributors }: { contributors: Contributor[] }) => {
5 | return (
6 |
7 | {contributors.sort((a, b) => b.pp - a.pp).map((contributor, index) => (
8 |
9 | {contributor.name === "Bonus pp" ? Bonus pp : (
10 | {contributor.name}
11 | )}
12 | {contributor.pp.toFixed(2)}
13 |
14 | ))}
15 |
16 | )
17 | }
--------------------------------------------------------------------------------
/client/src/components/country/CountryDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Country } from "../../../../models/Country.model"
2 |
3 | export const CountryDetails = ({ details }: { details: Country }) => {
4 | return (
5 |
6 | {details.name === "Global" ? (
7 |
8 | ) : (
9 |
10 | )}
11 |
12 | {details.name}
13 | {parseFloat(details.pp).toFixed(0)}pp
14 | Acc {(details.acc*100).toFixed(2)}%
15 | Range {parseFloat(details.range).toFixed(0)}pp
16 | Farm {details.farm}%
17 |
18 |
19 | )
20 | }
--------------------------------------------------------------------------------
/client/src/components/country/CountryGraphs.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core"
2 | import axios from "axios"
3 | import { useEffect, useState } from "react"
4 | import { CountryStat } from "../../../../models/CountryStat.model"
5 | import GraphDropdown, { Option } from '../graphs/GraphDropdown'
6 | import { TimeSeriesChart } from "../graphs/TimeSeriesChart"
7 | import { GraphData } from "../graphs/util"
8 |
9 | export const countryOptions: Option[] = [
10 | { value: "pp", label: "Performance", reversed: false },
11 | { value: "acc", label: "Accuracy", reversed: false },
12 | { value: "farm", label: "Farm", reversed: false },
13 | { value: "range", label: "Range", reversed: false },
14 | { value: "playerWeighting", label: "Player Weighted pp", reversed: false },
15 | ];
16 |
17 | export const CountryGraphs = ({ name }: { name: string }) => {
18 | const [graphDataMap, setGraphDataMap] = useState>(new Map())
19 | const [isLoading, setLoading] = useState(true)
20 | const [graphType, setGraphType] = useState(countryOptions[0])
21 |
22 | const graphChange = (e: Option | null) => {
23 | setGraphType(e??countryOptions[0])
24 | }
25 |
26 | useEffect(() => {
27 | axios.get(`/api/countries/${name}/stats`).then(res => {
28 | const map = new Map()
29 | for (const stat of res.data) {
30 | const date = stat.date;
31 |
32 | const obj: any = {
33 | "acc": stat.acc === 0 ? null : stat.acc,
34 | "pp": parseInt(stat.pp),
35 | }
36 |
37 | // these were added in later versions so are not present in older data
38 | obj["farm"] = stat?.farm??null
39 | obj["range"] = stat?.range??null
40 | obj["playerWeighting"] = stat?.playerWeighting??null
41 |
42 | for (const key of Object.keys(obj)) {
43 | if (obj[key] == null) continue
44 | map.set(key, (map.get(key)??[]).concat({ x: date, y: obj[key] }));
45 | }
46 | }
47 |
48 | for (const key of map.keys()) {
49 | map.set(key, (map.get(key)??[]).sort((a, b) => a.x - b.x))
50 | }
51 |
52 | setGraphDataMap(map)
53 | setLoading(false)
54 | })
55 | }, [name])
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
63 |
64 | {isLoading ? : }
65 |
66 |
67 |
68 | )
69 | }
--------------------------------------------------------------------------------
/client/src/components/country/CountryPlayers.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core";
2 | import axios from "axios";
3 | import { useEffect, useState } from "react"
4 | import { CountryPlayers as CountryPlayersModel } from "../../../../models/CountryPlayers.model";
5 | import CountryPlayersGraph from "../graphs/CountryPlayersGraph";
6 |
7 | export const CountryPlayers = ({ name }: { name: string }) => {
8 | const [data, setData] = useState([])
9 |
10 | useEffect(() => {
11 | axios.get(`/api/countries/${name}/players`).then(res => {
12 | setData(res.data.filter(item => !item.listPlayers.find(player => player.name === undefined)))
13 | })
14 | }, [name])
15 |
16 | return (
17 |
18 |
19 |
20 | Top 10 Players
21 |
22 |
23 | {!data.length ? : }
24 |
25 |
26 |
27 | )
28 | }
--------------------------------------------------------------------------------
/client/src/components/graphs/CompareGraph.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useContext } from "react";
2 | import stc from "string-to-color";
3 | import { v4 as uuidv4 } from "uuid";
4 | import moment from "moment";
5 | import {
6 | CartesianGrid,
7 | Line,
8 | Tooltip,
9 | LineChart,
10 | XAxis,
11 | YAxis,
12 | } from "recharts";
13 | import { truncate } from "./util";
14 | import { CompareData } from "../pages/Compare";
15 | import { CustomResponsiveContainer } from "./CustomResponsiveContainer";
16 | import { ThemeContext } from "../../ThemeProvider";
17 |
18 | interface TooltipTableItem {
19 | name: string
20 | data: {
21 | [property: string]: number
22 | }
23 | }
24 |
25 | interface DataPoint {
26 | date: number;
27 | name: string;
28 | data: {
29 | [property: string]: number
30 | }
31 | }
32 |
33 | export default function CompareGraph({ compare, type, reversed }: { compare: CompareData[], type: string, reversed: boolean }) {
34 | const [points, setPoints] = useState([]);
35 | const [names, setNames] = useState>(new Set());
36 | const theme = useContext(ThemeContext);
37 |
38 | const CustomTooltip = ({ active, payload, label }: any) => {
39 | let table: TooltipTableItem[] = [];
40 | points.forEach((point) => {
41 | if (point.date + 4.64e7 > label && point.date - 4.64e7 < label) {
42 | table.push({ name: point.name, data: { [type]: point.data[point.name] } });
43 | }
44 | });
45 |
46 | const nameSet = new Set();
47 | table = table.filter((item) => {
48 | if (nameSet.has(item.name)) {
49 | return false;
50 | } else {
51 | nameSet.add(item.name);
52 | return true;
53 | }
54 | });
55 |
56 | table = table.sort((a: any, b: any) => parseFloat(b.data[type]) - parseFloat(a.data[type]));
57 |
58 | if (active) {
59 | return (
60 |
61 |
62 | {moment(label).format("DD M YY")}
63 | {table.map((data, index) => (
64 |
65 | {data.name + " " + truncate(data.data[type])}
66 |
67 | ))}
68 |
69 |
70 | );
71 | }
72 | return null;
73 | };
74 |
75 | useEffect(() => {
76 | let dataPoints: DataPoint[] = [];
77 | const nameList = new Set();
78 | const clone: CompareData[] = JSON.parse(JSON.stringify(compare))
79 | let count = 0;
80 |
81 | let lowestPoint = Number.MAX_VALUE;
82 | clone.forEach((item) => {
83 | if (!item.added || !item.data) {
84 | return;
85 | }
86 |
87 | count++;
88 | item.data = item.data.sort((a, b) => a.date - b.date);
89 |
90 | item.data.forEach((point, index) => {
91 | const name = item.user ? point.player : point.name;
92 |
93 | point["acc"] = item.user ? point["acc"] : point["acc"] * 100;
94 | if (point["acc"] === 0) {
95 | return;
96 | }
97 |
98 | const value = parseFloat(parseFloat(point[type]).toFixed(2));
99 |
100 | if (value < lowestPoint) {
101 | lowestPoint = value;
102 | }
103 |
104 | point[type] = dataPoints.push({
105 | date: point.date,
106 | data: { [name]: value },
107 | name: name,
108 | });
109 |
110 | nameList.add(name);
111 | });
112 | });
113 |
114 | dataPoints = dataPoints
115 | .filter((item) => item.data[item.name] !== lowestPoint)
116 | .sort((a, b) => a.date - b.date);
117 |
118 | if (count > 1) {
119 | let lastName = dataPoints[0].name;
120 | dataPoints = dataPoints.filter((point) => {
121 | if (lastName === point.name) {
122 | return false;
123 | } else {
124 | lastName = point.name;
125 |
126 | if (Number.isNaN(point.data[point.name])) {
127 | return false;
128 | }
129 |
130 | return true;
131 | }
132 | });
133 | }
134 |
135 | setNames(nameList);
136 | setPoints(dataPoints);
137 | }, [compare, type]);
138 |
139 | return (
140 |
141 |
142 |
143 |
144 | moment(unixTime).format("MMM Do YY")}
150 | type="number"
151 | />
152 |
153 |
160 | } />
161 | {Array.from(names).map((name) => {
162 | return (
163 | obj.data[name]}
167 | strokeWidth={4}
168 | stroke={stc(name)}
169 | connectNulls={true}
170 | activeDot={{ stroke: stc(name), strokeWidth: 2, r: 4 }}
171 | isAnimationActive={false}
172 | />
173 | );
174 | })}
175 |
176 |
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/client/src/components/graphs/CountryPlayersGraph.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useContext } from "react";
2 | import stc from "string-to-color";
3 | import { v4 as uuidv4 } from "uuid";
4 | import moment from "moment";
5 | import {
6 | CartesianGrid,
7 | Line,
8 | Tooltip,
9 | LineChart,
10 | XAxis,
11 | YAxis,
12 | } from "recharts";
13 | import { CountryPlayers } from "../../../../models/CountryPlayers.model";
14 | import { ThemeContext } from "../../ThemeProvider";
15 | import { CustomResponsiveContainer } from "./CustomResponsiveContainer";
16 |
17 | interface TooltipTableItem {
18 | name: string
19 | pp: number
20 | }
21 |
22 | interface PlayerPoint {
23 | date: number;
24 | name: string;
25 | data: {
26 | [property: string]: number
27 | }
28 | }
29 |
30 | interface Player {
31 | name: string;
32 | colour: string;
33 | }
34 |
35 | export default function CountryPlayersGraph({ players }: { players: CountryPlayers[] }) {
36 | const [playerPoints, setPlayerPoints] = useState([]);
37 | const [playerNames, setPlayerNames] = useState([]);
38 | const theme = useContext(ThemeContext);
39 |
40 | const CustomTooltip = ({ active, payload, label }: any) => {
41 | let table: TooltipTableItem[] = [];
42 | playerPoints.forEach((point) => {
43 | if (point.date === label) {
44 | table.push({ name: point.name, pp: point.data[point.name] });
45 | }
46 | });
47 |
48 | if (active) {
49 | return (
50 |
51 |
52 | {moment(label).format("DD M YY")}
53 | {table.map((data, index) => (
54 |
55 | {data.name + " " + data.pp + "pp"}
56 |
57 | ))}
58 |
59 |
60 | );
61 | }
62 | return null;
63 | };
64 |
65 | useEffect(() => {
66 | let dataPoints: PlayerPoint[] = [];
67 | let playerList: Player[] = [];
68 |
69 | players.sort((a, b) => a.date - b.date);
70 |
71 | players.forEach((point) => {
72 | point.listPlayers.forEach((player, index) => {
73 | dataPoints.push({
74 | date: point.date,
75 | name: player.name,
76 | data: {
77 | [player.name]: parseFloat(player.pp),
78 | }
79 | });
80 | let flag = true;
81 | playerList.forEach((name) => {
82 | if (name.name === player.name) {
83 | flag = false;
84 | }
85 | });
86 | if (flag)
87 | playerList.push({ name: player.name, colour: stc(player.name) });
88 | });
89 | });
90 |
91 | setPlayerNames(playerList);
92 | setPlayerPoints(dataPoints);
93 | }, [players]);
94 |
95 | return (
96 |
97 |
98 |
99 |
100 | moment(unixTime).format("MMM Do YY")}
106 | type="number"
107 | />
108 |
109 |
110 | } />
111 | {playerNames.map((player) => {
112 | return (
113 | obj.data[player.name]}
117 | strokeWidth={4}
118 | stroke={player.colour}
119 | connectNulls={true}
120 | activeDot={{ stroke: player.colour, strokeWidth: 2, r: 4 }}
121 | isAnimationActive={false}
122 | />
123 | );
124 | })}
125 |
126 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/client/src/components/graphs/CustomResponsiveContainer.tsx:
--------------------------------------------------------------------------------
1 | import { ResponsiveContainer } from 'recharts';
2 |
3 | export const CustomResponsiveContainer = (props: any) => {
4 | return (
5 |
18 | );
19 | }
--------------------------------------------------------------------------------
/client/src/components/graphs/GraphDropdown.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import Select, { SingleValue } from "react-select";
3 | import { ThemeContext } from "../../ThemeProvider";
4 | import { getTheme } from "../../util/selectTheme";
5 |
6 | export interface Option {
7 | value: string,
8 | label: string,
9 | reversed?: boolean
10 | }
11 |
12 | export default function GraphDropdown({ onChange, selected, options }: {
13 | onChange: (option: Option | null) => void,
14 | selected: Option,
15 | options: Option[]
16 | }) {
17 | const select = (event: SingleValue) => {
18 | onChange(event);
19 | };
20 | const theme = useContext(ThemeContext);
21 |
22 | return (
23 |
24 |
Graph:
25 |
26 | item.value === selected.value)
31 | : options[0]
32 | }
33 | onChange={select}
34 | isSearchable={false}
35 | theme={(selectTheme) => getTheme(theme, selectTheme)}
36 | />
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/components/graphs/TimeScatterGraph.tsx:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 | import { useContext, useEffect, useState } from "react";
3 | import { CartesianGrid, Scatter, ScatterChart, Tooltip, XAxis, YAxis } from "recharts"
4 | import { ThemeContext } from "../../ThemeProvider";
5 | import { CustomResponsiveContainer } from "./CustomResponsiveContainer"
6 |
7 | export const TimeScatterGraph = ({ chartData, tz }: { chartData: { date: string, pp: string }[], tz: number }) => {
8 | const [data, setData] = useState([])
9 | const theme = useContext(ThemeContext);
10 |
11 | // need to convert dates to a time format for the graph, eg 0000 as midnight and 2359 as 11:59pm
12 | useEffect(() => {
13 | setData(chartData.map(item => {
14 | const date = new Date(item.date)
15 | let hours = ((date.getHours() + tz) % 24) * 100
16 |
17 | // if hours has gone negative, wrap it around, eg -5 hours -> 19 hours
18 | if (hours < 0) {
19 | hours += 2400
20 | }
21 |
22 | const minutes = date.getMinutes()
23 | return { x: hours + minutes, y: parseInt(item.pp) }
24 | }))
25 | }, [chartData, tz])
26 |
27 | const CustomTooltip = ({ active, payload, label }: any) => {
28 | if (active) {
29 | return (
30 |
31 | Time: {payload && formatTickTime(payload[0].value)}
32 | {payload && payload[1].value}pp
33 |
34 | );
35 | }
36 | return null;
37 | };
38 |
39 | const formatTickTime = (time: number) => {
40 | // 0 => 12:00am
41 | // 330 => 3:30am
42 | // 1230 => 12:30pm
43 | // 2359 => 11:59pm
44 | const hours = Math.floor(time / 100)
45 | const minutes = time % 100
46 | let minute = "0"
47 |
48 | if (minutes === 0) {
49 | minute = "00"
50 | } else if (minutes < 10) {
51 | minute = `0${minutes}`
52 | } else {
53 | minute = minutes.toString()
54 | }
55 |
56 | const ampm = hours >= 12 ? 'pm' : 'am'
57 | const hour = hours % 12
58 | return `${hour === 0 ? 12 : hour}:${minute}${hours === 24 ? "am" : ampm}`
59 | }
60 |
61 | return (
62 |
63 |
64 |
68 |
69 | formatTickTime(time)}
76 | type="number"
77 | />
78 |
85 | }/>
86 |
87 |
88 |
89 |
90 | )
91 | }
--------------------------------------------------------------------------------
/client/src/components/graphs/TimeSeriesChart.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useState } from "react";
2 | import moment from "moment";
3 | import {
4 | CartesianGrid,
5 | Line,
6 | Tooltip,
7 | LineChart,
8 | XAxis,
9 | YAxis,
10 | ReferenceArea
11 | } from "recharts";
12 | import { CustomResponsiveContainer } from "./CustomResponsiveContainer";
13 | import { GraphData, truncate } from "./util";
14 | import { ThemeContext } from "../../ThemeProvider";
15 |
16 | export const TimeSeriesChart = ({ chartData, reversed }: { chartData: GraphData[], reversed: boolean }) => {
17 | const [refAreaLeft, setRefAreaLeft] = useState("");
18 | const [refAreaRight, setRefAreaRight] = useState("");
19 | const [bottom, setBottom] = useState("dataMin-1")
20 | const [top, setTop] = useState("dataMax+1")
21 | const [left, setLeft] = useState("dataMin")
22 | const [right, setRight] = useState("dataMax")
23 |
24 | useEffect(() => {
25 | zoomOut()
26 | }, [chartData])
27 |
28 | const getAxisYDomain = (
29 | from: number,
30 | to: number,
31 | offset: number
32 | ) => {
33 | const first = chartData.findIndex(item => item.x === from)
34 | const second = chartData.findIndex(item => item.x === to)
35 | const refData: GraphData[] = chartData.slice(first - 1, second);
36 | let [bottom, top] = [refData[0].y, refData[0].y];
37 |
38 | refData.forEach((d) => {
39 | if (d.y > top) top = d.y;
40 | if (d.y < bottom) bottom = d.y;
41 | });
42 |
43 | return [bottom - offset, top + offset];
44 | };
45 |
46 | const zoom = () => {
47 | if (refAreaLeft === refAreaRight || refAreaRight === "") {
48 | setRefAreaLeft("");
49 | setRefAreaRight("");
50 | return;
51 | }
52 |
53 | let bottom, top: number
54 | if (refAreaLeft > refAreaRight) {
55 | [bottom, top] = getAxisYDomain(refAreaRight as number, refAreaLeft as number, 1);
56 | } else {
57 | [bottom, top] = getAxisYDomain(refAreaLeft as number, refAreaRight as number, 1);
58 | }
59 |
60 | setBottom(bottom)
61 | setTop(top)
62 | setLeft(refAreaLeft)
63 | setRight(refAreaRight)
64 | setRefAreaLeft("");
65 | setRefAreaRight("");
66 | }
67 |
68 | const zoomOut = () => {
69 | setRefAreaLeft("");
70 | setRefAreaRight("");
71 | setBottom("dataMin");
72 | setTop("dataMax+1");
73 | setLeft("dataMin")
74 | setRight("dataMax")
75 | }
76 |
77 | const theme = useContext(ThemeContext);
78 | const CustomTooltip = ({ active, payload, label }: any) => {
79 | if (active) {
80 | return (
81 |
82 |
83 | {payload && moment(label).format("DD M YY") + " : " + truncate(payload[0].value)}
84 |
85 |
86 | );
87 | }
88 | return null;
89 | };
90 |
91 | return (
92 |
93 | zoomOut()}>Drag to zoom, click here to zoom out
94 |
95 | e && setRefAreaLeft(e.activeLabel)}
99 | onMouseMove={(e: any) => e && refAreaLeft && setRefAreaRight(e.activeLabel)}
100 | onMouseUp={zoom.bind(this)}>
101 |
102 |
103 | moment(unixTime).format("MMM Do YY")}
110 | type="number"
111 | />
112 |
121 | } />
122 |
129 | {refAreaLeft && refAreaRight ? (
130 |
136 | ) : null}
137 |
138 |
139 |
140 | );
141 | };
142 |
--------------------------------------------------------------------------------
/client/src/components/graphs/util.ts:
--------------------------------------------------------------------------------
1 | export interface GraphData {
2 | x: number,
3 | y: number,
4 | }
5 | export const truncate = (graphPoint: number) => {
6 | let data = graphPoint
7 | const billion = 10e8;
8 | const million = 10e5;
9 | const thousand = 10e2;
10 | let divisor = 1;
11 | let extension = "";
12 |
13 | if (data / billion > 1) {
14 | divisor = billion;
15 | extension = "B";
16 | } else if (data / million > 1) {
17 | divisor = million;
18 | extension = "M";
19 | } else if (data / thousand > 1) {
20 | divisor = thousand;
21 | extension = "K";
22 | }
23 |
24 | if (divisor !== 1) {
25 | data = data / divisor;
26 | }
27 |
28 | if (!Number.isInteger(data)) {
29 | return data.toFixed(2) + extension
30 | }
31 |
32 | return data + extension;
33 | };
--------------------------------------------------------------------------------
/client/src/components/misc/ChangeLog.tsx:
--------------------------------------------------------------------------------
1 | import logs from '../../resources/changelog.json'
2 | import { Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core'
3 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
4 |
5 | export const ChangeLog = () => {
6 | return (
7 |
8 |
Changelog
9 | {logs.map((log, index) => (
10 |
11 | }
13 | aria-controls="panel1a-content"
14 | id="panel1a-header"
15 | className="dark:bg-dark02"
16 | >
17 |
18 | {log.version}
19 | -
20 | {log.date}
21 |
22 |
23 |
24 |
25 |
26 | {log.newFeatures.map((feature, index) => (
27 |
28 | +
29 | {feature}
30 |
31 | ))}
32 |
33 |
34 | {log.bugFixes.map((bug, index) => (
35 |
36 | -
37 | {bug}
38 |
39 | ))}
40 |
41 |
42 |
43 |
44 | ))}
45 |
46 | )
47 | }
--------------------------------------------------------------------------------
/client/src/components/misc/SimpleSummaryAccordion.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion, AccordionDetails, AccordionSummary } from "@material-ui/core"
2 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
3 |
4 | interface Props {
5 | title: string
6 | expanded?: true
7 | }
8 |
9 | export const SimpleSummaryAccordion: React.FC= ({ title, expanded, children }) => {
10 | return (
11 |
12 | }
14 | aria-controls="panel1a-content"
15 | id="panel1a-header"
16 | className="dark:bg-dark02"
17 | >
18 | {title}
19 |
20 |
21 | {children}
22 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/client/src/components/misc/TopPlays.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import axios from "axios";
3 | import { UserPlays as UserPlaysModel } from '../../../../models/UserPlays.model'
4 | import { Score } from "../../../../models/Score";
5 | import ArrowBackIcon from '@material-ui/icons/ArrowBack';
6 | import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
7 | import { TopPlaysDatePicker } from './TopPlaysDatePicker'
8 |
9 | interface PlaysHistory {
10 | scores: Score[]
11 | date: number
12 | }
13 |
14 | export const TopPlays = ({ currentTop, path, country }: { path: string, currentTop: Score[], country?: true }) => {
15 | const [playsHistory, setPlaysHistory] = useState([])
16 | const [index, setIndex] = useState(-1);
17 | const [isLoading, setLoading] = useState(true)
18 | const [unique, setUnique] = useState(false)
19 |
20 | const scoreEquator = (a: Score, b: Score) => {
21 | return a.acc === b.acc && a.id === b.id && a.pp === b.pp
22 | }
23 |
24 | // plays history is stored as deltas in the database, so each change has some [added] and [removed] plays with an associated date
25 | // we need to construct the actual history from this
26 | // example:
27 | // We have user details which contains the current top plays
28 | // We have a plays history with 1 change
29 | // The result will be a history array with two elements containing a list of the top plays and the date of the change
30 |
31 | useEffect(() => {
32 | axios.get(path).then(res => {
33 | const history: PlaysHistory[] = [{ scores: currentTop, date: Date.now() }];
34 |
35 | // start from the latest change
36 | for (const change of res.data.reverse()) {
37 | for (const play of change.added) {
38 | const index = history[0].scores.findIndex(score => scoreEquator(play, score));
39 | if (index !== -1) {
40 | history[0].scores[index].added = true
41 | }
42 | }
43 |
44 | // need to compare against the last scores, so we copy them into the local history
45 | const localHistory: PlaysHistory = { scores: JSON.parse(JSON.stringify(history[0].scores)), date: change.date };
46 | localHistory.scores = localHistory.scores.filter(score => !score.added)
47 |
48 | // add the removed plays
49 | const removed = change.removed;
50 | if (removed) {
51 | for (const play of removed) {
52 | const index = localHistory.scores.findIndex(score => scoreEquator(play, score));
53 | if (index === -1) {
54 | localHistory.scores.push(play);
55 | }
56 | }
57 | }
58 |
59 | history[0].date = change.date
60 | history.unshift(localHistory);
61 | }
62 |
63 | if (unique) {
64 | for (const date of history) {
65 | const idMap = new Set()
66 | date.scores = date.scores.filter(item => {
67 | if (idMap.has(item.id)) {
68 | return false
69 | }
70 | idMap.add(item.id)
71 | return true
72 | })
73 | }
74 | }
75 |
76 | if (index === -1) {
77 | setIndex(history.length-1)
78 | }
79 |
80 | setPlaysHistory(history)
81 | setLoading(false)
82 | })
83 | }, [currentTop, path, unique, index])
84 |
85 | return !isLoading ? (
86 |
87 |
88 |
setIndex(index-1)}
90 | className={`${index===0 && 'invisible'} cursor-pointer hover:text-red-500 transition duration-200 ease-in`}
91 | />
92 |
93 | setIndex(i === -1 ? index : i)} selected={new Date(playsHistory[index].date)} dates={playsHistory.map(item => item.date)} />
94 |
95 | setIndex(index+1)}
97 | className={`${index===(playsHistory.length-1) && 'invisible'} cursor-pointer hover:text-green-500 transition duration-200 ease-in`}
98 | />
99 |
100 |
101 |
Note: At the time of a pp rework, many plays will be green
102 |
103 | {country &&
104 |
setUnique(!unique)} className="button button-green shadow-sm">
105 | {unique ? "Show All Plays" : "Hide Duplicate Maps"}
106 |
107 | }
108 |
109 |
110 | {playsHistory[index].scores.sort((a,b) => parseFloat(b.pp) - parseFloat(a.pp)).map((item, index) => (
111 |
118 |
135 |
136 | {item.mods.join(",")}
137 | {(item.acc*100).toFixed(2)}%
138 | {parseFloat(item.pp).toFixed(0)}pp
139 |
140 |
141 | ))}
142 |
143 |
144 | ) : null
145 | }
146 |
147 | const diffName = (name: string) => name.match(/(\[(.*?)\])$/g)??"".slice(-1)[0];
148 |
149 | const artist = (name: string) => name.split(" - ")[0]
150 |
151 | const songName = (name: string) =>
152 | name
153 | .replace(/(\[(.*?)\])$/g, "")
154 | .replace(artist(name), "")
155 | .replace(/- /, "");
156 |
--------------------------------------------------------------------------------
/client/src/components/misc/TopPlaysDatePicker.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react';
2 | import DatePicker from 'react-datepicker'
3 | import "react-datepicker/dist/react-datepicker.css";
4 |
5 | export const TopPlaysDatePicker = ({ selected, dates, onClick }: { selected: Date, dates: number[], onClick: (i: number) => void }) => {
6 | const CustomInput = forwardRef(({ value, onClick }: any, ref: React.LegacyRef) => (
7 |
12 | {value}
13 |
14 | ))
15 |
16 | const onChange = (date: Date) => {
17 | // the datepicker changes the date by a little bit for some reason so we can only compare the first 5 digits
18 | selected = date
19 | onClick(dates.findIndex(date => date.toString().substring(0, 5) === selected.getTime().toString().substring(0, 5)))
20 | }
21 |
22 | return (
23 |
24 | new Date(date))}
28 | customInput={ }
29 | allowSameDay={true}
30 | />
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/client/src/components/navigation/DarkMode.tsx:
--------------------------------------------------------------------------------
1 | import Brightness2Icon from '@material-ui/icons/Brightness2'
2 | import WbSunnyIcon from '@material-ui/icons/WbSunny'
3 | import { useContext } from 'react';
4 | import { ThemeContext } from "../../ThemeProvider";
5 |
6 | export const DarkMode = () => {
7 | const theme = useContext(ThemeContext);
8 |
9 | return (
10 |
11 | theme?.toggle()}>
12 | {theme?.mode === "dark" ? : }
13 |
14 |
15 | )
16 | }
--------------------------------------------------------------------------------
/client/src/components/navigation/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { GitHub } from "@material-ui/icons"
2 | import TwitterIcon from "@material-ui/icons/Twitter";
3 |
4 | export const Footer = () => {
5 | return (
6 |
34 | )
35 | }
--------------------------------------------------------------------------------
/client/src/components/navigation/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 | import { Link } from 'react-router-dom'
3 | import { HomeLink } from "./HomeLink";
4 | import { Menu } from "./Menu";
5 | import { Search } from "./Search";
6 | import { DarkMode } from "./DarkMode";
7 | import { Animated } from "react-animated-css";
8 | import { Spin as Hamburger } from 'hamburger-react'
9 |
10 | export interface MenuItem {
11 | name: string
12 | path: string
13 | external?: boolean
14 | }
15 |
16 | const menuItems: MenuItem[] = [
17 | { name: "Home", path: "/" },
18 | { name: "Historic", path: "/historic" },
19 | { name: "Compare", path: "/compare" },
20 | { name: "All", path: "/all" },
21 | { name: "Stats", path: "/stats" },
22 | { name: "API", path: "https://james-46.gitbook.io/api-docs/", external: true }
23 | ]
24 |
25 | export const Header = () => {
26 | const [active, setActive] = useState("")
27 | const [menu, setMenu] = useState(false)
28 |
29 | useEffect(() => {
30 | // read the browser path and set menu item if applicable
31 | const path = window.location.pathname
32 | const match = path.match(/\/\w*/g)
33 | if (match) {
34 | const menuItem = menuItems.find(item => item.path === match[0])
35 | if (menuItem) {
36 | setActive(menuItem.name)
37 | }
38 | }
39 | }, [])
40 |
41 | useEffect(() => {
42 | setMenu(false)
43 | }, [active])
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
setMenu(!menu)}>
53 |
54 |
55 |
56 |
57 |
58 | {menu && (
59 |
60 |
61 |
62 | )}
63 |
64 |
65 |
66 |
67 |
68 | {menuItems.map((item) => (
69 | item.external ? (
70 |
{item.name}
71 | ) : (
72 |
setActive(item.name)}>
77 | {item.name}
78 |
79 | )
80 | ))}
81 |
82 |
83 |
84 |
90 |
91 |
92 | )
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/client/src/components/navigation/HomeLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom"
2 |
3 | export const HomeLink = ({ setActive }: { setActive: React.Dispatch> }) => {
4 | return (
5 | setActive("Home")} className="items-center flex text-white hover:text-black transition ease-in duration-200">
6 |
11 | osuTracker
12 |
13 | )
14 | }
--------------------------------------------------------------------------------
/client/src/components/navigation/Menu.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom"
2 | import { MenuItem } from "./Header"
3 | import { Search } from "./Search"
4 |
5 | export const Menu = ({ items, setActive, active }: { items: MenuItem[], setActive: React.Dispatch>, active: string }) => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | {items.map((item) => (
13 |
setActive(item.name)}>
18 | {item.name}
19 |
20 | ))}
21 |
22 | )
23 | }
--------------------------------------------------------------------------------
/client/src/components/navigation/Search.tsx:
--------------------------------------------------------------------------------
1 | import SearchIcon from "@material-ui/icons/Search";
2 | import axios from "axios";
3 | import { useEffect, useState } from "react";
4 | import { Animated } from "react-animated-css";
5 | import { useDebounce } from "use-debounce/lib";
6 | import { GenericSummary, SearchRes } from "../../../../interfaces/search";
7 | import { CompareData } from "../pages/Compare";
8 | import { SearchResults } from "./SearchResults";
9 |
10 | interface Props {
11 | alwaysFocused?: boolean,
12 | text?: string,
13 | select?: (selected: GenericSummary, itemToChange: CompareData) => void
14 | item?: CompareData
15 | }
16 |
17 | export const Search = ({ alwaysFocused, text, select, item }: Props) => {
18 | const [results, setResults] = useState();
19 | const [search, setSearch] = useState("");
20 | const [focused, setFocused] = useState(alwaysFocused ? true : false);
21 | const [debouncedSearchTerm] = useDebounce(search, 250)
22 | const [isLoading, setLoading] = useState(true)
23 |
24 | useEffect(() => {
25 | getFiltered(1, "");
26 | }, []);
27 |
28 | useEffect(() => {
29 | getFiltered(1, debouncedSearchTerm);
30 | }, [debouncedSearchTerm])
31 |
32 | const toggleFocus = (on: boolean) => {
33 | if (!alwaysFocused) {
34 | setTimeout(() => setFocused(on), 250);
35 | }
36 | }
37 |
38 | const getFiltered = async (page: number, text: string) => {
39 | setLoading(true)
40 | const res = await axios.get(
41 | `/api/search/all?page=${page}&text=${text}`
42 | );
43 |
44 | for (const item of res.data.page) {
45 | if (item.type === "country") {
46 | if (item.name === "Global") {
47 | item.url =
48 | "https://upload.wikimedia.org/wikipedia/commons/e/ef/International_Flag_of_Planet_Earth.svg";
49 | } else {
50 | item.url =
51 | "https://purecatamphetamine.github.io/country-flag-icons/3x2/" +
52 | item.id +
53 | ".svg";
54 | }
55 | } else {
56 | item.url = "http://s.ppy.sh/a/" + item.id;
57 | }
58 | }
59 |
60 | setLoading(false)
61 | setResults(res.data.page);
62 | };
63 |
64 | return (
65 |
66 |
67 |
71 | toggleFocus(true)}
74 | onBlur={() => toggleFocus(false)}
75 | onChange={(e) => setSearch(e.target.value)}
76 | placeholder={text ? text : "Search..."}
77 | />
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/client/src/components/navigation/SearchResults.tsx:
--------------------------------------------------------------------------------
1 | import { GenericSummary } from "../../../../interfaces/search";
2 | import { CompareData } from "../pages/Compare";
3 |
4 | interface Props {
5 | results: GenericSummary[] | undefined,
6 | loading: boolean,
7 | select?: (selected: GenericSummary, itemToChange: CompareData) => void
8 | item?: CompareData
9 | }
10 |
11 | export const SearchResults = ({ results, loading, select, item }: Props) => {
12 | const parseLink = (item: GenericSummary) => {
13 | if (item.type === "country") {
14 | return "/country/" + item.name;
15 | } else {
16 | return "/user/" + item.id
17 | }
18 | }
19 |
20 | const goto = (result: GenericSummary) => {
21 | if (select && item) {
22 | select(result, item)
23 | } else {
24 | window.location.href = parseLink(result)
25 | }
26 | }
27 |
28 | return (
29 |
30 | {loading ? (
31 | Array.from(Array(5).keys()).map(i => (
32 |
Loading...
33 | ))
34 | ) : (
35 | results && results.length ? results.map((result, index) => (
36 |
goto(result)} className="flex items-center justify-between p-2 hover:text-white hover:bg-blue-400 dark:hover:bg-blue-500 transition rounded-sm cursor-pointer" key={index}>
37 | {result.name}
38 | {parseInt(result.pp)}pp
39 |
40 | )) :
No Results!
41 | )}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/client/src/components/pages/All.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { CountriesTable } from "../country/CountriesTable";
3 | import { UsersTable } from "../user/UsersTable";
4 | import { useSearchParams } from "react-router-dom";
5 | import { Helmet } from "react-helmet";
6 |
7 | enum Mode {
8 | USERS = "Users",
9 | COUNTRIES = "Countries",
10 | COUNTRY_USERS = "Country Users"
11 | }
12 |
13 | const modes = [Mode.USERS, Mode.COUNTRIES]
14 |
15 | export const All = () => {
16 | const [mode, setMode] = useState(Mode.USERS);
17 | const [country, setCountry] = useState("")
18 | const [params] = useSearchParams()
19 |
20 | useEffect(() => {
21 | const name = params.get("country")
22 | if (name) {
23 | setMode(Mode.COUNTRY_USERS);
24 | setCountry(name);
25 | } else {
26 | setMode(Mode.USERS);
27 | }
28 | }, [params])
29 |
30 | return (
31 |
32 |
33 |
34 |
38 |
42 |
46 |
47 |
48 |
52 |
53 |
54 | {mode === Mode.COUNTRY_USERS ? (
55 |
56 | ) : (
57 |
58 |
59 | {modes.map((m, index) => (
60 | setMode(m)}
63 | className={`
64 | ${m === mode && 'bg-blue-400 dark:text-blue-500 dark:bg-transparent text-white'}
65 | ${index === 0 ? 'border-r rounded-tl-md' : 'rounded-tr-md'}
66 | py-2 w-full text-center border-gray-300 dark:border-black hover:bg-blue-400 dark:hover:bg-transparent dark:hover:text-blue-500 transition duration-100 ease-in
67 | `}
68 | >{m}
69 | ))}
70 |
71 | {mode === Mode.USERS && (
72 |
73 | )}
74 | {mode === Mode.COUNTRIES && (
75 |
76 | )}
77 |
78 | )}
79 |
80 |
81 | )
82 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Compare.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { v4 as uuidv4 } from "uuid";
3 | import axios, { AxiosResponse } from "axios";
4 | import { Country } from "../../../../models/Country.model";
5 | import { User } from "../../../../models/User.model";
6 | import { parseUrl } from "../../util/parseUrl";
7 | import { SimpleSummaryAccordion } from "../misc/SimpleSummaryAccordion";
8 | import { UserStat } from "../../../../models/UserStat.model";
9 | import { CountryStat } from "../../../../models/CountryStat.model";
10 | import { userOptions } from "../user/UserGraphs";
11 | import { countryOptions } from "../country/CountryGraphs";
12 | import CompareGraph from "../graphs/CompareGraph";
13 | import GraphDropdown, { Option } from "../graphs/GraphDropdown";
14 | import { CircularProgress } from "@material-ui/core";
15 | import { Search } from "../navigation/Search";
16 | import stc from "string-to-color";
17 | import CloseIcon from '@material-ui/icons/Close';
18 | import AddIcon from '@material-ui/icons/Add';
19 | import { GenericSummary } from "../../../../interfaces/search";
20 | import { Helmet } from "react-helmet";
21 |
22 | export interface CompareData {
23 | name: string;
24 | added: boolean;
25 | id: string;
26 | user: boolean;
27 | data?: any[];
28 | }
29 |
30 | const defaultCompare: CompareData[] = [
31 | { name: "", added: false, id: uuidv4(), user: true },
32 | { name: "", added: false, id: uuidv4(), user: true },
33 | ];
34 |
35 | export const Compare = () => {
36 | const [compare, setCompare] = useState([]);
37 | const [adding, setAdding] = useState(0);
38 | const [isLoading, setLoading] = useState(true);
39 | const [noGet, setNoGet] = useState(false);
40 | const [length, setLength] = useState(0);
41 | const [graphType, setGraphType] = useState(userOptions[0]);
42 | const [fixedLink, setFixedLink] = useState(false);
43 | const maxCompare = 50;
44 |
45 | // kind of a mess. there are 3 "modes" of the compare view, the main one is just having
46 | // the user enter any users/countries they want to compare, the other two are fixed links
47 | // for the top 10 players and the top 10 countries.
48 | useEffect(() => {
49 | document.title = "Compare";
50 | const urlOptions = parseUrl();
51 |
52 | if (urlOptions.topUsers) {
53 | setFixedLink(true);
54 | axios.get("/api/users/topUserIds").then((res) => {
55 | const data = res.data;
56 | setCompare(
57 | data.map((user) => ({
58 | name: user.id,
59 | user: true,
60 | added: true,
61 | id: uuidv4(),
62 | }))
63 | );
64 | });
65 | } else if (urlOptions.topCountries) {
66 | setFixedLink(true);
67 | axios.get("/api/countries/limitedAll").then((res) => {
68 | const data = res.data
69 | .sort((a, b) => b.playerWeighting - a.playerWeighting)
70 | .slice(1, 11);
71 | setCompare(
72 | data.map((country) => ({
73 | name: country.name,
74 | user: false,
75 | added: true,
76 | id: uuidv4(),
77 | }))
78 | );
79 | });
80 | } else {
81 | if (urlOptions.users.length + urlOptions.countries.length < 2) {
82 | setCompare(defaultCompare);
83 | setLoading(false);
84 | setAdding(0);
85 | } else {
86 | const compareData: CompareData[] = [];
87 | for (const user of urlOptions.users) {
88 | compareData.push({
89 | name: user.toString(),
90 | user: true,
91 | added: true,
92 | id: uuidv4(),
93 | });
94 | }
95 |
96 | for (const country of urlOptions.countries) {
97 | compareData.push({
98 | name: country,
99 | user: false,
100 | added: true,
101 | id: uuidv4(),
102 | });
103 | }
104 |
105 | setCompare(compareData);
106 | setGraphType(userOptions[0]);
107 | }
108 | }
109 | }, []);
110 |
111 | useEffect(() => {
112 | if (compare.length) {
113 | // this will update the url to reflect the compare list without refreshing the page
114 | if (!fixedLink) {
115 | let string = "?";
116 |
117 | compare.forEach((item, index) => {
118 | item.name = item.name ?? "";
119 | string += index + 1 + "=" + item.name + "&";
120 | });
121 |
122 | window.history.replaceState(null, "Compare", string);
123 | }
124 |
125 | // noGet is true if ???
126 | if (!noGet) {
127 | let count = 0;
128 | const promises: {
129 | promise: Promise>;
130 | user: boolean;
131 | }[] = [];
132 | compare.forEach((item) => {
133 | if (!item.name) {
134 | return;
135 | }
136 |
137 | count++;
138 | let header: {
139 | promise: Promise>;
140 | user: boolean;
141 | };
142 | if (item.user) {
143 | header = {
144 | promise: axios.get(`/api/users/${item.name}/stats`),
145 | user: true,
146 | };
147 | } else {
148 | header = {
149 | promise: axios.get(`/api/countries/${item.name}/stats`),
150 | user: false,
151 | };
152 | }
153 |
154 | promises.push(header);
155 | });
156 |
157 | Promise.all(
158 | promises.map((header) => {
159 | return header.promise.then((res) =>
160 | handleData(res.data, header.user)
161 | );
162 | })
163 | ).then(() => {
164 | setLoading(false);
165 | });
166 |
167 | const handleData = (
168 | data: CountryStat[] | UserStat[],
169 | user: boolean
170 | ) => {
171 | if (data.length) {
172 | const item = compare.find(
173 | ({ name }) =>
174 | name ===
175 | (user
176 | ? (data[0] as UserStat).id
177 | : (data[0] as CountryStat).name)
178 | );
179 |
180 | if (item) {
181 | item.data = data;
182 | }
183 | }
184 | };
185 |
186 | setLength(count);
187 | }
188 | setNoGet(false);
189 | }
190 | }, [compare, noGet, fixedLink]);
191 |
192 | const addNew = () => {
193 | const newItem = {
194 | name: "",
195 | user: false,
196 | added: false,
197 | id: uuidv4(),
198 | };
199 |
200 | setNoGet(true);
201 | setCompare([...compare, newItem]);
202 | };
203 |
204 | const remove = (toRemove: CompareData) => {
205 | const removed = compare.filter((item) => item.id !== toRemove.id);
206 | setNoGet(true);
207 | setCompare(removed);
208 | };
209 |
210 | const select = (selected: GenericSummary, itemToChange: CompareData) => {
211 | const user = selected.type === "user";
212 |
213 | itemToChange.name = user ? selected.id : selected.name;
214 | itemToChange.user = user;
215 | itemToChange.added = true;
216 |
217 | setNoGet(false);
218 | setLoading(true);
219 | setCompare([...compare]);
220 | };
221 |
222 | const graphChange = (e: Option) => {
223 | setGraphType(e);
224 | };
225 |
226 | const options = () => {
227 | const table = [];
228 |
229 | if (compare.find((item) => item.user)) {
230 | table.push(...userOptions);
231 | }
232 |
233 | if (compare.find((item) => !item.user)) {
234 | table.push(...countryOptions);
235 | }
236 |
237 | return table;
238 | };
239 |
240 | return (
241 |
242 |
243 |
244 |
248 |
252 |
256 |
257 |
258 |
262 |
263 |
278 |
279 |
280 | {compare.map((item, index) => (
281 |
282 |
remove(item)}
284 | className="text-white px-1 py-1 rounded bg-red-500 hover:bg-red-400 transition ease-in"
285 | >
286 |
287 |
288 |
289 |
300 |
301 | {item.data && (
302 |
303 |
304 |
316 | {item.user &&
#{item.data[item.data.length - 1].rank} }
317 |
318 |
319 |
320 | {parseFloat(item.data[item.data.length - 1].pp).toFixed(0)}pp
321 |
322 | {item.user ? (
323 | {parseFloat(item.data[item.data.length - 1].acc).toFixed(2)}%
324 | ) : (
325 | {(parseFloat(item.data[item.data.length - 1].acc) * 100).toFixed(2)}%
326 | )}
327 |
328 |
329 | )}
330 |
331 | ))}
332 |
333 | {((compare.length + adding) < maxCompare) && (
334 |
338 |
339 |
340 | )}
341 |
342 |
343 |
344 |
345 | {length > 0 &&
346 |
347 |
348 |
349 | graphChange(o ?? options()[0])}
353 | />
354 |
355 |
356 | {isLoading ? (
357 |
358 | ) : (
359 |
364 | )}
365 |
366 |
367 |
368 | }
369 |
370 | );
371 | };
372 |
--------------------------------------------------------------------------------
/client/src/components/pages/Country.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { useEffect, useState } from 'react'
3 | import { Link, useParams } from 'react-router-dom'
4 | import { Country as CountryDetailsModel } from '../../../../models/Country.model'
5 | import { CountryContributors } from '../country/CountryContributors'
6 | import { CountryDetails } from '../country/CountryDetails'
7 | import { CountryGraphs } from '../country/CountryGraphs'
8 | import { CountryPlayers } from '../country/CountryPlayers'
9 | import { SimpleSummaryAccordion } from '../misc/SimpleSummaryAccordion'
10 | import { TopPlays } from '../misc/TopPlays'
11 | import { Loading } from './Loading'
12 | import { Helmet } from "react-helmet";
13 |
14 | export const Country = () => {
15 | const [country, setCountry] = useState()
16 | const params = useParams<"name">()
17 | const name = params.name??"Global"
18 |
19 | useEffect(() => {
20 | document.title = name + "'s Profile"
21 | const fetchData = async () => {
22 | const { data } = await axios.get(`/api/countries/${name}/details`)
23 | setCountry(data)
24 | }
25 |
26 | fetchData()
27 | }, [name])
28 |
29 | return country !== undefined ? (
30 |
31 |
32 |
36 |
40 |
48 |
56 |
60 |
61 |
65 |
66 |
67 | {name !== "Global" &&
68 |
View Player List
69 | }
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | ) :
84 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Historic.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { useContext, useEffect, useState } from "react"
3 | import { HistoricTop } from '../../../../models/HistoricTop.model'
4 | import Select from 'react-select'
5 | import { ThemeContext } from "../../ThemeProvider"
6 | import { Loading } from "./Loading"
7 | import { getTheme } from "../../util/selectTheme"
8 |
9 | // add notable events
10 |
11 | interface YearOption {
12 | value: number
13 | label: string
14 | }
15 |
16 | const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
17 |
18 | export const Historic = () => {
19 | const [allHistoric, setAllHistoric] = useState([])
20 | const [selectedHistoric, setSelectedHistoric] = useState()
21 | const [isLoading, setLoading] = useState(true)
22 | const [visualization, setVisualization] = useState(false)
23 | const [years, setYears] = useState([])
24 | const [selectedYear, setSelectedYear] = useState(2012)
25 | const [selectedMonth, setSelectedMonth] = useState("April")
26 | const [availableMonths, setAvailableMonths] = useState>(new Set())
27 | const theme = useContext(ThemeContext);
28 |
29 | useEffect(() => {
30 | document.title = "Historic Top Players"
31 | axios.get("/api/stats/historicTop").then(res => {
32 | setAllHistoric(res.data)
33 | setLoading(false)
34 |
35 | const yearSet = new Set()
36 | const yearOptions: YearOption[] = []
37 | res.data.forEach(h => yearSet.add(h.year))
38 |
39 | for (const year of yearSet) {
40 | yearOptions.push({ value: year, label: year.toString() })
41 | }
42 |
43 | setYears(yearOptions)
44 | })
45 | }, [])
46 |
47 | useEffect(() => {
48 | const monthSet = new Set()
49 | const possible = allHistoric.filter(x => x.year === selectedYear)
50 | for (const item of possible) {
51 | monthSet.add(item.month)
52 | }
53 |
54 | setAvailableMonths(monthSet)
55 |
56 | for (const month of months) {
57 | if (monthSet.has(month)) {
58 | setSelectedMonth(month)
59 | break
60 | }
61 | }
62 | }, [selectedYear, allHistoric])
63 |
64 | useEffect(() => {
65 | setSelectedHistoric(allHistoric.find(x => x.year === selectedYear && x.month === selectedMonth))
66 | }, [selectedYear, selectedMonth, allHistoric])
67 |
68 | return !isLoading ? (
69 |
70 |
71 | {visualization && (
72 |
73 |
80 |
81 | )}
82 |
83 |
84 |
Historic Top Players
85 | Have a look through the history of osu!'s top players (by performance) from 2013-2020.
86 | *Note: The day of the month for each data point was not consistent.
87 | {visualization ? (
88 | setVisualization(false)}>Hide Visualization
89 | ) : (
90 | setVisualization(true)}>View Visualization (May Lag)
91 | )}
92 |
93 |
94 |
95 |
96 |
Year:
97 |
98 | e && setSelectedYear(e.value)}
102 | defaultValue={{ value: selectedYear, label: selectedYear.toString() }}
103 | theme={(selectTheme) => getTheme(theme, selectTheme)}
104 | />
105 |
106 |
107 |
108 |
109 |
Month:
110 |
111 | {months.map(item => (
112 | setSelectedMonth(item)}
116 | className={`${item === selectedMonth && 'bg-green-400 dark:bg-green-400 text-white cursor-default'} ${availableMonths.has(item) ? 'dark:bg-gray-700 bg-white hover:bg-green-400 dark:hover:bg-green-400 transition duration-200 ease-in shadow-md cursor-pointer' : 'bg-gray-300 dark:bg-dark03 cursor-default'} px-2 py-1 rounded`}
117 | >
118 | {item}
119 |
120 | ))}
121 |
122 |
123 |
124 |
125 |
126 |
Top 50
127 |
128 |
129 |
130 | Rank
131 | Player
132 | pp
133 |
134 |
135 | {(selectedHistoric?.top??[]).map((item, index) => (
136 |
137 |
138 | #{index+1}
139 |
140 | {item.name}
141 |
142 |
143 | {item.pp}
144 |
145 |
146 |
147 | ))}
148 |
149 |
150 |
151 | ) :
152 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from '../navigation/Search'
2 | import { useEffect, useState } from 'react'
3 | import axios, { AxiosError } from 'axios'
4 | import CountryPlayersGraph from '../graphs/CountryPlayersGraph'
5 | import { CountryPlayers } from "../../../../models/CountryPlayers.model";
6 | import { CircularProgress } from '@material-ui/core';
7 | import { ChangeLog } from '../misc/ChangeLog'
8 | import { toast, ToastOptions } from "react-toastify";
9 | import "react-toastify/dist/ReactToastify.css";
10 | import { Helmet } from "react-helmet";
11 |
12 | toast.configure()
13 |
14 | const showcases = [
15 | { title: "Countries", description: "See a countries' overall performance, accuracy, top 100 plays and more." },
16 | { title: "Farm", description: "See what percentage of your plays are from the most played mapsets." },
17 | { title: "Global Stats", description: "See the top 10 player history, top plays history, overall performance and more using data from all top players." },
18 | { title: "Top Plays", description: "See an in depth view of how your top plays have changed over time." },
19 | { title: "Historic Top 50 Players", description: "Old screenshots and website archives have been searched through to construct a view of the top 50 throughout osu! history." }
20 | ]
21 |
22 | export const Home = () => {
23 | const [isLoading, setLoading] = useState(true)
24 | const [playerData, setPlayerData] = useState([])
25 | const [numberUsers, setNumberUsers] = useState(12773)
26 | const [numberCountries, setNumberCountries] = useState(150)
27 | const [nameInput, setNameInput] = useState("")
28 | const [adding, setAdding] = useState(false)
29 |
30 | useEffect(() => {
31 | document.title = "osuTracker"
32 |
33 | const fetchData = async () => {
34 | const { data: numberUsers } = await axios.get("/api/users/number")
35 | const { data: numberCountries } = await axios.get("/api/countries/number")
36 | const { data: countryPlayers } = await axios.get("/api/countries/Global/players")
37 | setPlayerData(countryPlayers.slice(-90))
38 | setNumberUsers(numberUsers)
39 | setNumberCountries(numberCountries)
40 | setLoading(false)
41 | }
42 |
43 | fetchData()
44 | }, [])
45 |
46 | const toastSetting: ToastOptions = {
47 | position: "bottom-right",
48 | autoClose: 5000,
49 | hideProgressBar: false,
50 | closeOnClick: true,
51 | pauseOnHover: true,
52 | draggable: true,
53 | progress: undefined,
54 | className: "",
55 | };
56 |
57 | const add = async () => {
58 | setAdding(true)
59 |
60 | const res = await axios.post("/api/users/add", { name: nameInput }).catch((err: AxiosError) => {
61 | if (err.response!.status === 404) {
62 | toast.error("osu! Account not found!", toastSetting)
63 | } else if (err.response!.status === 409) {
64 | toast.error("Already Exists!", toastSetting)
65 | }
66 | })
67 |
68 | if (res && res.status === 201) {
69 | toast.success(nameInput + " Added!", toastSetting)
70 | }
71 |
72 | setTimeout(() => {
73 | setAdding(false)
74 | }, 1000)
75 | }
76 |
77 | return (
78 |
79 |
80 |
81 |
85 |
89 |
93 |
94 |
95 |
99 |
100 |
101 |
102 |
Welcome to osuTracker
103 |
A pathway to deep, granular, historic data.
104 |
105 |
106 |
107 |
108 |
109 |
110 | 90 Day Global Top 10 History
111 |
112 |
113 | {isLoading ? : }
114 |
115 |
116 |
117 |
118 |
119 |
120 |
Get Started
121 | Start tracking yourself (or another person)
122 |
123 |
124 | add()}
127 | className={`w-16 ${(adding || nameInput === "") ? 'dark:bg-gray-400 bg-gray-500 cursor-default' : 'bg-green-400 dark:bg-green-500 hover:bg-green-300 dark:hover:bg-green-400'} button transition ease-in text-white`}
128 | >
129 | {adding ? : Add }
130 |
131 | setNameInput(e.target.value)}
133 | placeholder="osu! Username"
134 | className="shadow-md rounded-md h-10 w-40 lg:w-60 border-gray-200 border-2 pl-2 text-gray-900 dark:text-white dark:bg-dark03 dark:border-transparent"/>
135 |
136 |
137 |
138 |
139 |
Unique data sets
140 | {showcases.map(showcase => (
141 |
142 |
{showcase.title}
143 | {showcase.description}
144 |
145 | ))}
146 |
147 |
148 |
149 |
150 |
Currently tracking {numberUsers} players and {numberCountries} countries.
151 | Data updated daily, for free, forever.
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
Check out Batch Beatmap Downloader!
162 |
Features:
163 |
- Mass download osu! beatmaps
164 |
- Most ranked, loved, and tournament maps
165 |
- Inbuilt Stream/Farm filters
166 |
- Add downloaded maps to new collections
167 |
- Advanced query builder
168 |
174 | Click here to check it out!
175 |
176 |
177 |
178 |
179 | )
180 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core"
2 |
3 | export const Loading = () => {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Redirect.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useEffect } from "react";
3 | import { useParams } from "react-router-dom";
4 |
5 | export const Redirect = () => {
6 | const params = useParams<"name">()
7 | const name = params.name??"YEP"
8 |
9 | useEffect(() => {
10 | axios.get("/api/users/" + name + "/getId").then(res => {
11 | window.location.replace("/user/" + res.data);
12 | });
13 | }, [name]);
14 |
15 | return
;
16 | }
--------------------------------------------------------------------------------
/client/src/components/pages/Stats.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { useEffect, useState } from "react"
3 | import { OverallStats } from '../../../../models/OverallStats.model'
4 | import { Loading } from "./Loading"
5 | import { SimpleSummaryAccordion } from "../misc/SimpleSummaryAccordion"
6 | import { TopMapsets } from "../stats/TopMapsets"
7 | import { TopPlay } from "../stats/TopPlay"
8 | import { Helmet } from "react-helmet";
9 | import { PPBarriers } from "../stats/PPBarriers"
10 |
11 | export const Stats = () => {
12 | const [stats, setStats] = useState()
13 |
14 | useEffect(() => {
15 | document.title = "Stats"
16 | axios.get("/api/stats/").then(res => {
17 | setStats(res.data)
18 | })
19 | }, [])
20 |
21 | return stats ? (
22 |
23 |
24 |
25 |
29 |
33 |
37 |
38 |
39 |
43 |
44 |
45 |
Average Overall Stats
46 |
47 |
48 | Acc: {stats.userStats.acc.toFixed(2)}%
49 | Farm: {stats.userStats.farm.toFixed(0)}%
50 | Play Length: {stats.userStats.lengthPlay.toFixed(0)}s
51 | Objects/Play: {stats.userStats.objectsPlay.toFixed(0)}
52 | pp: {stats.userStats.pp.toFixed(0)}
53 | Range: {stats.userStats.range.toFixed(0)}
54 | Playcount: {stats.userStats.plays.toFixed(0)}
55 | Joined Date: {new Date(stats.userStats.timeJoined).toLocaleDateString()}
56 |
57 |
58 |
59 |
60 |
61 | Acc: {(stats.countryStats.acc*100).toFixed(2)}%
62 | Farm: {stats.countryStats.farm.toFixed(0)}%
63 | Play Length: {stats.countryStats.lengthPlay.toFixed(0)}s
64 | Objects/Play: {stats.countryStats.objectsPlay.toFixed(0)}
65 | pp: {stats.countryStats.pp.toFixed(0)}
66 | Range: {stats.countryStats.range.toFixed(0)}
67 |
68 |
69 |
70 |
71 |
72 | Most Common Top Play
73 |
74 |
75 |
76 |
77 |
Most Common x pp Plays
78 |
79 |
80 |
81 |
82 |
Top Mod Combinations (by count)
83 |
84 |
85 | {stats.userStats.modsCount.slice(0,100).map((mod, index) => (
86 |
87 | {index+1}
88 | {mod.mods.length ? mod.mods.join(",") : "None"}
89 | {mod.count}
90 |
91 | ))}
92 |
93 |
94 |
95 |
96 |
97 |
Top 100 Mappers (by count)
98 |
99 |
100 | {stats.mapperCount.slice(0,100).map((mapper, index) => (
101 |
102 | {index+1}
103 | {mapper.mapper}
104 | {mapper.count}
105 |
106 | ))}
107 |
108 |
109 |
110 |
111 |
112 | Top 727 Beatmap Sets (by count)
113 |
114 |
115 |
116 |
117 |
118 | ) :
119 | }
--------------------------------------------------------------------------------
/client/src/components/pages/User.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 | import { UserDetails } from "../user/UserDetails"
3 | import { UserGraphs } from "../user/UserGraphs"
4 | import { User as UserDetailsModel } from '../../../../models/User.model'
5 | import axios from "axios"
6 | import { Loading } from "./Loading"
7 | import { TopPlays } from "../misc/TopPlays"
8 | import { useParams } from "react-router-dom"
9 | import { Helmet } from "react-helmet";
10 | import { TimeScatterGraph } from "../graphs/TimeScatterGraph"
11 | import TimezoneSelect from "react-timezone-select"
12 |
13 | export const User = () => {
14 | const [details, setDetails] = useState()
15 | const [isLoading, setLoading] = useState(true)
16 | const params = useParams<"id">()
17 | const id = params.id??"9008211"
18 | const [offset, setOffset] = useState(new Date().getTimezoneOffset() / 60 * -1)
19 | const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
20 |
21 | useEffect(() => {
22 | axios.get("/api/users/" + id).then(res => {
23 | document.title = res.data.name + "'s Profile"
24 | setDetails(res.data)
25 | setLoading(false)
26 | })
27 | }, [id])
28 |
29 | return !isLoading ? (
30 |
31 |
32 |
33 |
37 |
41 |
45 |
49 |
50 |
54 |
55 |
56 | {details && }
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
Top Play Time Scatter
65 |
66 | {
69 | if (tz.offset) {
70 | setOffset(tz.offset)
71 | }
72 | setTimezone(tz.value)
73 | }}
74 | />
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | {details && details.currentTop.length ?
83 |
84 | {details && }
85 |
:
86 |
87 | Come back later to see your top score history.
88 |
89 | }
90 |
91 | ) :
92 | }
--------------------------------------------------------------------------------
/client/src/components/stats/PPBarriers.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core"
2 | import axios from "axios"
3 | import { useEffect, useState } from "react"
4 | import { PPBarrierRes } from '../../../../interfaces/stats'
5 | import { SimpleSummaryAccordion } from "../misc/SimpleSummaryAccordion"
6 |
7 | export const PPBarriers = () => {
8 | const [data, setData] = useState([])
9 |
10 | useEffect(() => {
11 | axios.get("/api/stats/ppBarrier").then(res => {
12 | setData(res.data)
13 | })
14 | }, [])
15 |
16 | return (
17 |
18 | {data.length ? data.sort((a,b) => a.number - b.number).map((item, index) => (
19 |
20 |
21 | {item.list.map((barrier, index) => (
22 | <>
23 |
#{index+1}
24 |
{barrier.name}
25 |
{barrier.count}
26 | >
27 | ))}
28 |
29 |
30 | )) :
}
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/client/src/components/stats/TopMapsets.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 | import { SetCount } from "../../../../models/OverallStats.model"
3 | import { CircularProgress } from "@material-ui/core"
4 | import axios from "axios"
5 |
6 | interface Beatmap {
7 | name: string,
8 | mapper: string
9 | }
10 |
11 | export const TopMapsets = ({ sets }: { sets: SetCount[] }) => {
12 | const [fullSets, setFullSets] = useState([])
13 |
14 | useEffect(() => {
15 | const setIdArray = sets.map(item => item.setId)
16 | const batchNumber = 100
17 | const batches = Math.ceil(setIdArray.length / batchNumber)
18 |
19 | const fetchData = async () => {
20 | const allMaps: Beatmap[] = []
21 | for (let i = 0; i < batches; i++) {
22 | const data = await axios.get("/api/stats/mapsets", {
23 | params: { arr: setIdArray.slice(i * batchNumber, (i + 1) * batchNumber) }
24 | })
25 | allMaps.push(...data.data)
26 | }
27 |
28 | return allMaps
29 | }
30 |
31 | fetchData().then(res => {
32 | setFullSets(res)
33 | })
34 | }, [sets])
35 |
36 | return (
37 |
57 | )
58 | }
--------------------------------------------------------------------------------
/client/src/components/stats/TopPlay.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core"
2 | import axios from "axios"
3 | import { useEffect, useState } from "react"
4 | import { Beatmap } from '../../../../models/Beatmap.model'
5 |
6 | export const TopPlay = ({ id }: { id: string }) => {
7 | const [play, setPlay] = useState()
8 |
9 | useEffect(() => {
10 | axios.get("/api/stats/mapset/" + id).then(res => {
11 | setPlay(res.data)
12 | })
13 | }, [id])
14 |
15 | return (
16 |
28 | )
29 | }
--------------------------------------------------------------------------------
/client/src/components/user/UserDetails.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { useEffect, useState } from "react"
3 | import { Link } from "react-router-dom"
4 | import { User } from "../../../../models/User.model"
5 |
6 | export const UserDetails = ({ details }: { details: User }) => {
7 | const [countryFull, setCountryFull] = useState("New Zealand")
8 |
9 | useEffect(() => {
10 | axios.get("/api/countries/" + details.country).then(res => {
11 | setCountryFull(res.data)
12 | })
13 | }, [details])
14 |
15 | return (
16 |
17 |
18 |
19 |
37 |
38 |
{parseFloat(details.pp).toFixed(0)}pp
39 |
Rank #{details.rank}
40 |
Acc {parseFloat(details.acc).toFixed(2)}%
41 |
Level {details.level.toFixed(1)}
42 |
Range {parseFloat(details.range).toFixed(0)}pp
43 |
Farm {details.farm}%
44 |
{details.plays} Plays
45 |
Joined {new Date(details.joined).toLocaleDateString()}
46 |
47 |
48 | )
49 | }
--------------------------------------------------------------------------------
/client/src/components/user/UserGraphs.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from "@material-ui/core"
2 | import axios from "axios"
3 | import { useEffect, useState } from "react"
4 | import { UserStat } from "../../../../models/UserStat.model"
5 | import GraphDropdown, { Option } from '../graphs/GraphDropdown'
6 | import { TimeSeriesChart } from "../graphs/TimeSeriesChart"
7 | import { GraphData } from "../graphs/util"
8 |
9 | export const userOptions: Option[] = [
10 | { value: "pp", label: "Performance" },
11 | { value: "rank", label: "Rank", reversed: true },
12 | { value: "acc", label: "Accuracy" },
13 | { value: "plays", label: "Play Count" },
14 | { value: "farm", label: "Farm" },
15 | { value: "range", label: "Range" },
16 | { value: "score", label: "Score" },
17 | { value: "countryRank", label: "Country Rank", reversed: true },
18 | ]
19 |
20 | export const UserGraphs = ({ id }: { id: string }) => {
21 | const [graphDataMap, setGraphDataMap] = useState>(new Map())
22 | const [isLoading, setLoading] = useState(true)
23 | const [graphType, setGraphType] = useState(userOptions[0])
24 |
25 | const graphChange = (e: Option | null) => {
26 | setGraphType(e??userOptions[0])
27 | }
28 |
29 | useEffect(() => {
30 | axios.get(`/api/users/${id}/stats`).then(res => {
31 | const map = new Map()
32 | for (const stat of res.data) {
33 | const date = stat.date;
34 |
35 | const obj: any = {
36 | "acc": Math.round((parseFloat(stat.acc) + Number.EPSILON) * 100) / 100,
37 | "plays": parseInt(stat.plays),
38 | "pp": parseInt(stat.pp),
39 | "rank": parseInt(stat.rank),
40 | }
41 |
42 | // these were added in later versions so are not present in older data
43 | obj["countryRank"] = stat?.countryRank??null
44 | obj["farm"] = stat?.farm??null
45 | obj["range"] = stat?.range??null
46 | obj["score"] = stat?.score??null
47 |
48 | for (const key of Object.keys(obj)) {
49 | if (obj[key] == null) continue
50 | map.set(key, (map.get(key)??[]).concat({ x: date, y: obj[key] }));
51 | }
52 | }
53 |
54 | for (const key of map.keys()) {
55 | map.set(key, (map.get(key)??[]).sort((a, b) => a.x - b.x))
56 | }
57 |
58 | setGraphDataMap(map)
59 | setLoading(false)
60 | })
61 | }, [id])
62 |
63 | return (
64 |
65 |
66 |
67 |
68 |
69 |
70 | {isLoading ? : }
71 |
72 |
73 |
74 | )
75 | }
--------------------------------------------------------------------------------
/client/src/components/user/UsersTable.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useEffect, useState } from "react";
3 | import { User } from "../../../../models/User.model";
4 | import ArrowBackIcon from '@material-ui/icons/ArrowBack';
5 | import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
6 |
7 | interface FilterUserRes {
8 | data: User[]
9 | numberResults: number
10 | }
11 |
12 | interface Header {
13 | name: string
14 | value: string
15 | mobile?: boolean
16 | }
17 |
18 | const headers: Header[] = [
19 | { name: "Rank", value: "rank", mobile: true },
20 | { name: "Name", value: "name", mobile: true },
21 | { name: "pp", value: "pp", mobile: true },
22 | { name: "Acc", value: "acc", mobile: true },
23 | { name: "Farm", value: "farm", mobile: true },
24 | { name: "Range", value: "range", mobile: true },
25 | { name: "Level", value: "level" },
26 | { name: "Joined", value: "joined" },
27 | { name: "Objects/Play", value: "averageObjects" }
28 | ]
29 |
30 | export const UsersTable = ({ country }: { country?: string }) => {
31 | const [data, setData] = useState([]);
32 | const [numberResults, setNumberResults] = useState(0);
33 | const [page, setPage] = useState(1);
34 | const [sorting, setSorting] = useState("pp")
35 | const [order, setOrder] = useState("desc")
36 | const pageSize = 50
37 |
38 | useEffect(() => {
39 | document.title = "All Users"
40 | const link = "/api/" + (country ? "countries/" : "users/") + "allFilter" + (country ? "/" + country : "")
41 |
42 | axios
43 | .get(link, { params: { name: sorting, order: order, page: page },})
44 | .then((res) => {
45 | setData(res.data.data.filter(item => item.rank !== "0" && item.farm !== -1));
46 | setNumberResults(res.data.numberResults);
47 | });
48 | }, [page, order, sorting, country])
49 |
50 | const parseSorting = (value: string) => {
51 | if (value === sorting) {
52 | if (order === "desc") {
53 | setOrder("asc")
54 | } else {
55 | setOrder("desc")
56 | }
57 | } else {
58 | setSorting(value)
59 | }
60 | }
61 |
62 | return (
63 |
64 |
65 |
66 |
67 | {headers.map((header) => (
68 | parseSorting(header.value)} className={`${!header.mobile && 'hidden md:table-cell'} hover:underline cursor-pointer`} key={header.name}>{header.name}
69 | ))}
70 |
71 |
72 | {data?.map((d, index) => (
73 |
74 |
75 | {d.rank}
76 | {d.name}
77 | {d.pp}
78 | {d.acc}%
79 | {d.farm}%
80 | {parseFloat(d.range).toFixed(0)}
81 | {d.level}
82 | {new Date(d.joined).toLocaleDateString()}
83 | {d.averageObjects.toFixed(0)}
84 |
85 |
86 | ))}
87 |
88 |
89 |
setPage(page-1)}
91 | className={`${page===1 && 'invisible'} cursor-pointer hover:text-red-500 transition duration-200 ease-in`}
92 | />
93 | {((page-1)*pageSize) + 1} - {page*pageSize}
94 | setPage(page+1)}
96 | className={`${(page*pageSize) > numberResults && 'invisible'} cursor-pointer hover:text-green-500 transition duration-200 ease-in`}
97 | />
98 |
99 |
100 | )
101 | }
--------------------------------------------------------------------------------
/client/src/index.scss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | overflow-x: hidden;
7 | overflow-y: scroll;
8 | background: black;
9 | }
10 |
11 | #root {
12 | font-family: 'Inter', sans-serif;
13 | font-size: 16px;
14 | -webkit-font-smoothing: antialiased;
15 | -moz-osx-font-smoothing: grayscale;
16 | overflow-x: hidden;
17 | @apply dark:text-white
18 | }
19 |
20 | .recharts-text tspan {
21 | font-size: 0.75rem;
22 | @apply bg-white
23 | }
24 |
25 | svg.recharts-surface tspan {
26 | font-size: 0.8rem !important;
27 | color: white !important;
28 | font-family: Arial;
29 | }
30 |
31 | .text-stroke {
32 | -webkit-text-stroke: 0.1px black;
33 | }
34 |
35 | .main-width {
36 | @apply max-w-6xl
37 | }
38 |
39 | .z-search {
40 | z-index: 5000;
41 | }
42 |
43 | .min-w-60 {
44 | min-width: 15rem;
45 | }
46 |
47 | .main-container {
48 | min-height: calc(100vh - 4rem - 4rem);
49 | position: relative;
50 | margin-top: -320px;
51 | z-index: 25;
52 |
53 | @apply w-full main-width relative bg-gray-50 dark:bg-dark01 md:rounded-t -mt-72;
54 | }
55 |
56 | .xpad {
57 | @apply px-4 md:px-8 xl:px-24
58 | }
59 |
60 | .text-tiny {
61 | font-size: 0.6rem;
62 | }
63 |
64 | .centered td {
65 | text-align: center;
66 | }
67 |
68 | .force-w16 {
69 | min-width: 4rem;
70 | width: 4rem;
71 | }
72 |
73 | .force-w8 {
74 | min-width: 2rem;
75 | }
76 |
77 | .force-w12 {
78 | min-width: 3rem;
79 | }
80 |
81 | .force-w6 {
82 | min-width: 1.5rem;
83 | }
84 |
85 | .rtl {
86 | direction: rtl;
87 | }
88 |
89 | .button {
90 | @apply transition ease-in duration-200 px-4 py-1 rounded h-10 shadow-md text-white
91 | }
92 |
93 | .button-green {
94 | @apply bg-green-500 dark:bg-green-500 hover:bg-green-400 dark:hover:bg-green-400
95 | }
96 |
97 | .button-red {
98 | @apply bg-red-500 hover:bg-red-400
99 | }
100 |
101 | .header-button {
102 | @apply text-white pt-5 px-2 lg:px-4;
103 | }
104 |
105 | .header-button-active {
106 | @apply border-b-4 border-gray-800;
107 | }
108 |
109 | .header-button:hover {
110 | transition-property: border, color !important;
111 | @apply text-gray-800 border-b-4 border-gray-800 transition ease-in duration-200;
112 | }
113 |
114 | .header-menu-button {
115 | @apply text-gray-900 px-4 py-2 dark:text-white;
116 | }
117 |
118 | .header-menu-button-active {
119 | @apply text-blue-500 border-l-4 border-blue-400 px-3 bg-blue-50 dark:bg-transparent;
120 | }
121 |
122 | .header-menu-button:hover {
123 | @apply text-blue-500 border-l-4 border-blue-400 px-3 bg-blue-50 dark:bg-transparent transition ease-in duration-200;
124 | }
125 |
126 | .maps-grid {
127 | display: grid;
128 | grid-template-columns: 45px 1fr 45px;
129 | }
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.scss';
4 | import App from './App';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root')
11 | );
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/resources/changelog.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "version": "2.2",
4 | "date": "Dec 31 2021",
5 | "newFeatures": [
6 | "Added a calendar date picker for top play history",
7 | "Increased width of player names in country top plays"
8 | ],
9 | "bugFixes": [
10 | "Fixed issue where undefined data appeared on country top 10 players graph"
11 | ]
12 | },
13 | {
14 | "version": "2.1",
15 | "date": "Dec 12 2021",
16 | "newFeatures": [
17 | "Added scatter plot for users to show the time of day of each play in their top 100",
18 | "Changed home page history to be over the past 90 days",
19 | "Increased width of main website container"
20 | ],
21 | "bugFixes": [
22 | "Fixed some user avatars displaying at the incorrect size"
23 | ]
24 | },
25 | {
26 | "version": "2.0",
27 | "date": "Nov 1 2021",
28 | "newFeatures": [
29 | "Full UI Redesign",
30 | "Added history page and data",
31 | "New API documentation using GitBook",
32 | "Added changelog",
33 | "Graph brushes replaced with zooming via dragging"
34 | ],
35 | "bugFixes": [
36 | "Fixed top play history displaying incorrectly",
37 | "Fixed some map names being incorrectly cut off"
38 | ]
39 | }
40 | ]
--------------------------------------------------------------------------------
/client/src/resources/collection-helper.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nzbasic/osutracker/057a912b3e779fb71866a6c71b7d816d7cdbb18d/client/src/resources/collection-helper.png
--------------------------------------------------------------------------------
/client/src/util/parseUrl.ts:
--------------------------------------------------------------------------------
1 | import queryString from "query-string";
2 |
3 | const maxCompare = 50;
4 |
5 | export interface UrlOptions {
6 | users: number[];
7 | countries: string[];
8 | topCountries?: boolean;
9 | topUsers?: boolean;
10 | }
11 |
12 | export const parseUrl = () => {
13 | const pathname = window.location.pathname
14 | const options: UrlOptions = { users: [], countries: [] };
15 |
16 | if (pathname.endsWith("topCountries")) {
17 | options.topCountries = true;
18 | return options
19 | }
20 |
21 | if (pathname.endsWith("topUsers")) {
22 | options.topUsers = true;
23 | return options
24 | }
25 |
26 | const urlParams = queryString.parse(window.location.search, {
27 | arrayFormatSeparator: ",",
28 | arrayFormat: "bracket-separator",
29 | parseNumbers: true,
30 | });
31 |
32 | for (let i = 0; i < maxCompare; i++) {
33 | if (urlParams[i]) {
34 | const item = urlParams[i]
35 | if (typeof item === "number") {
36 | options.users.push(item)
37 | } else if (typeof item === "string") {
38 | options.countries.push(item)
39 | }
40 | }
41 | }
42 |
43 | return options
44 | }
--------------------------------------------------------------------------------
/client/src/util/selectTheme.ts:
--------------------------------------------------------------------------------
1 | import { Theme } from "react-select"
2 | import { Theme as DarkTheme } from '../ThemeProvider';
3 |
4 | export const getTheme = (theme: DarkTheme | null, selectTheme: Theme) => {
5 | return {
6 | ...selectTheme,
7 | colors: {
8 | ...selectTheme.colors,
9 | neutral0: theme?.mode === 'dark' ? '#212121' : selectTheme.colors.neutral0, // bg
10 | primary25: theme?.mode === 'dark' ? '#323232' : selectTheme.colors.primary25, // hover
11 | neutral20: theme?.mode === 'dark' ? '#000000' : selectTheme.colors.neutral20, // border
12 | neutral80: theme?.mode === 'dark' ? '#ffffff' : "black", // text
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
3 | darkMode: "class", // or 'media' or 'class'
4 | theme: {
5 | extend: {
6 | colors: {
7 | dark00: "#121212",
8 | dark01: "#1C1C1C",
9 | dark02: "#212121",
10 | dark03: "#242424",
11 | },
12 | },
13 | },
14 | variants: {
15 | extend: {
16 | backgroundColor: ["odd"],
17 | },
18 | },
19 | plugins: [],
20 | };
21 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/interfaces/country.ts:
--------------------------------------------------------------------------------
1 | import { CountryPlayers } from "../models/CountryPlayers.model";
2 |
3 | export interface CountryPlayersRes {
4 | data: CountryPlayers
5 | }
6 |
7 | export interface CountriesLimitedRes {
8 | name: string,
9 | abbreviation: string,
10 | pp: 1,
11 | acc: 1,
12 | farm: 1,
13 | range: 1,
14 | averageObjects: 1,
15 | playerWeighting: 1,
16 | }
--------------------------------------------------------------------------------
/interfaces/search.ts:
--------------------------------------------------------------------------------
1 | export interface SearchRes {
2 | page: Array
3 | length: number
4 | }
5 |
6 | export interface GenericSummary {
7 | type: string
8 | id: string
9 | name: string
10 | pp: string
11 | url?: string
12 | }
13 |
14 | export interface CountrySummary {
15 | abbreviation: string,
16 | name: string,
17 | pp: string
18 | }
19 |
20 | export interface UserSummary {
21 | id: string,
22 | name: string,
23 | pp: string
24 | }
--------------------------------------------------------------------------------
/interfaces/stats.ts:
--------------------------------------------------------------------------------
1 | export interface PPBarrierRes {
2 | number: number,
3 | list: { name: string, id: string, count: number }[]
4 | }
--------------------------------------------------------------------------------
/models/Beatmap.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface Beatmap {
4 | id: string,
5 | setId: string,
6 | name: string,
7 | maxCombo: string,
8 | objects: number,
9 | starRating: string,
10 | length: string,
11 | mapper: string,
12 | }
13 |
14 | const beatmapSchema = new Schema({
15 | id: String,
16 | setId: String,
17 | name: String,
18 | maxCombo: String,
19 | objects: Number,
20 | starRating: String,
21 | length: String,
22 | mapper: String,
23 | });
24 |
25 | export const BeatmapModel = model("beatmap", beatmapSchema);
26 |
--------------------------------------------------------------------------------
/models/BeatmapCount.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface BeatmapCount {
4 | id: number,
5 | count: number
6 | }
7 |
8 | const beatmapCountSchema = new Schema({
9 | id: Number,
10 | count: Number
11 | });
12 |
13 | export const BeatmapCountModel = model("beatmapCount", beatmapCountSchema);
14 |
--------------------------------------------------------------------------------
/models/BeatmapSetCount.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface BeatmapSetCount {
4 | setId: number,
5 | count: number
6 | }
7 |
8 | const beatmapSetCountSchema = new Schema({
9 | setId: Number,
10 | count: Number
11 | });
12 |
13 | export const BeatmapSetCountModel = model("beatmapSetCount", beatmapSetCountSchema);
14 |
--------------------------------------------------------------------------------
/models/Country.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 | import { ModCount } from './OverallStats.model';
3 | import { Score } from './Score';
4 |
5 | export interface Contributor {
6 | name: string
7 | pp: number
8 | }
9 |
10 | export interface Country {
11 | name: string,
12 | abbreviation: string,
13 | contributors: Contributor[],
14 | acc: number,
15 | pp: string,
16 | farm: number,
17 | scoresCurrent: Score[],
18 | range: string,
19 | playerWeighting: number,
20 | averageObjects: number,
21 | averageLength: number,
22 | modsCount: ModCount[],
23 | rank?: number
24 | }
25 |
26 | const countrySchema = new Schema({
27 | name: String,
28 | abbreviation: String,
29 | contributors: Array,
30 | acc: Number,
31 | pp: String,
32 | farm: Number,
33 | scoresCurrent: Array,
34 | range: String,
35 | playerWeighting: Number,
36 | averageObjects: Number,
37 | averageLength: Number,
38 | modsCount: Array,
39 | });
40 |
41 | export const CountryModel = model("country", countrySchema);
42 |
--------------------------------------------------------------------------------
/models/CountryPlayers.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface CountryPlayer {
4 | name: string
5 | pp: string
6 | }
7 |
8 | export interface CountryPlayers {
9 | name: string,
10 | date: number,
11 | listPlayers: CountryPlayer[],
12 | mark: number,
13 | }
14 |
15 | const countryPlayersSchema = new Schema({
16 | name: String,
17 | date: Number,
18 | listPlayers: Array,
19 | mark: Number,
20 | });
21 |
22 | export const CountryPlayersModel = model("countryPlayers", countryPlayersSchema);
23 |
--------------------------------------------------------------------------------
/models/CountryPlays.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 | import { Score } from './Score';
3 |
4 | export interface CountryPlays {
5 | name: string,
6 | date: number,
7 | added: Score[],
8 | removed: Score[]
9 | }
10 |
11 | const countryPlaysSchema = new Schema({
12 | name: String,
13 | date: Number,
14 | added: Array,
15 | removed: Array,
16 | });
17 |
18 | export const CountryPlaysModel = model("countryPlays", countryPlaysSchema);
--------------------------------------------------------------------------------
/models/CountryStat.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface CountryStat {
4 | name: string,
5 | date: number,
6 | pp: string,
7 | acc: number,
8 | range?: number,
9 | farm?: number,
10 | playerWeighting?: number,
11 | }
12 |
13 | const countryStatSchema = new Schema({
14 | name: String,
15 | date: Number,
16 | pp: String,
17 | acc: Number,
18 | range: Number,
19 | farm: Number,
20 | playerWeighting: Number,
21 | });
22 |
23 | export const CountryStatModel = model("countryStat", countryStatSchema);
--------------------------------------------------------------------------------
/models/HistoricTop.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface HistoricTopPlayerPoint {
4 | name: string,
5 | pp: number
6 | }
7 |
8 | export interface HistoricTop {
9 | year: number,
10 | month: string,
11 | monthNumber: number,
12 | top: HistoricTopPlayerPoint[]
13 | }
14 |
15 | const historicTopSchema = new Schema({
16 | year: Number,
17 | month: String,
18 | monthNumber: Number,
19 | top: Array
20 | });
21 |
22 | export const HistoricTopModel = model("historictop", historicTopSchema);
--------------------------------------------------------------------------------
/models/OverallStats.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface MapperCount {
4 | mapper: string,
5 | count: number
6 | }
7 |
8 | export interface SetCount {
9 | setId: string,
10 | count: number
11 | }
12 |
13 | export interface ModCount {
14 | mods: string[]
15 | count: number
16 | }
17 |
18 | export interface OverallStats {
19 | mapperCount: MapperCount[],
20 | setCount: SetCount[],
21 | userStats: {
22 | range: number,
23 | acc: number,
24 | plays: number,
25 | timeJoined: number,
26 | farm: number,
27 | topPlay: string,
28 | pp: number,
29 | level: number,
30 | lengthPlay: number,
31 | objectsPlay: number,
32 | modsCount: ModCount[],
33 | },
34 | countryStats: {
35 | range: number,
36 | farm: number,
37 | pp: number,
38 | acc: number,
39 | lengthPlay: number,
40 | objectsPlay: number,
41 | modsCount: ModCount[],
42 | },
43 | }
44 |
45 | const overallStats = new Schema({
46 | mapperCount: Array,
47 | setCount: Array,
48 | userStats: {
49 | range: Number,
50 | acc: Number,
51 | plays: Number,
52 | timeJoined: Number,
53 | farm: Number,
54 | topPlay: String,
55 | pp: Number,
56 | level: Number,
57 | lengthPlay: Number,
58 | objectsPlay: Number,
59 | modsCount: Array,
60 | },
61 | countryStats: {
62 | range: Number,
63 | farm: Number,
64 | pp: Number,
65 | acc: Number,
66 | lengthPlay: Number,
67 | objectsPlay: Number,
68 | modsCount: Array,
69 | },
70 | });
71 |
72 | export const OverallStatsModel = model("overallStats", overallStats);
--------------------------------------------------------------------------------
/models/PPBarrier.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface PPBarrierCount {
4 | setId: string,
5 | count: number,
6 | }
7 |
8 | export interface PPBarrier {
9 | number: Number,
10 | list: PPBarrierCount[]
11 | }
12 |
13 | const ppBarrierSchema = new Schema({
14 | number: Number,
15 | list: Array,
16 | });
17 |
18 | export const PPBarrierModel = model("ppBarrier", ppBarrierSchema);
--------------------------------------------------------------------------------
/models/Score.ts:
--------------------------------------------------------------------------------
1 | export interface Score {
2 | name: string,
3 | id: string,
4 | setId: string,
5 | mods: string[],
6 | pp: string,
7 | missCount: string,
8 | acc: number,
9 | mapper: string,
10 | length: string,
11 | objects: number,
12 | player?: string
13 | added?: boolean
14 | }
--------------------------------------------------------------------------------
/models/TopPlayCount.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface TopPlayCount {
4 | id: number,
5 | count: number
6 | }
7 |
8 | const topPlayCountSchema = new Schema({
9 | id: Number,
10 | count: Number
11 | });
12 |
13 | export const TopPlayCountModel = model("topPlayCount", topPlayCountSchema);
14 |
--------------------------------------------------------------------------------
/models/User.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 | import { Score } from './Score';
3 |
4 | export interface User {
5 | name: string,
6 | id: string,
7 | url: string,
8 | country: string,
9 | pp: string,
10 | rank: string,
11 | acc: string,
12 | plays: string,
13 | level: number,
14 | range: string,
15 | joined: number,
16 | currentTop: Score[],
17 | farm: number,
18 | averageLength: number,
19 | averageObjects: number,
20 | timesList: { date: string, pp: string }[]
21 | }
22 |
23 | const userSchema = new Schema({
24 | name: String,
25 | id: String,
26 | url: String,
27 | country: String,
28 | pp: String,
29 | rank: String,
30 | acc: String,
31 | plays: String,
32 | level: Number,
33 | range: String,
34 | joined: Number,
35 | currentTop: Array,
36 | farm: Number,
37 | averageLength: Number,
38 | averageObjects: Number,
39 | timesList: Array
40 | });
41 |
42 | export const UserModel = model("user", userSchema);
--------------------------------------------------------------------------------
/models/UserPlays.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 | import { Score } from './Score';
3 |
4 | export interface UserPlays {
5 | name: string,
6 | id: string,
7 | date: number,
8 | added: Score[],
9 | removed: Score[],
10 | }
11 |
12 | const userPlaysSchema = new Schema({
13 | name: String,
14 | id: String,
15 | date: Number,
16 | added: Array,
17 | removed: Array,
18 | });
19 |
20 | export const UserPlaysModel = model("userPlays", userPlaysSchema);
--------------------------------------------------------------------------------
/models/UserStat.model.ts:
--------------------------------------------------------------------------------
1 | import { Schema, model } from 'mongoose';
2 |
3 | export interface UserStat {
4 | rank: string,
5 | pp: string,
6 | plays: string,
7 | acc: string,
8 | player: string,
9 | date: number,
10 | id: string,
11 | countryRank?: number,
12 | farm?: number,
13 | range?: number,
14 | score?: number,
15 | }
16 |
17 | const userStatSchema = new Schema({
18 | rank: String,
19 | pp: String,
20 | plays: String,
21 | acc: String,
22 | player: String,
23 | date: Number,
24 | id: String,
25 | countryRank: Number,
26 | farm: Number,
27 | range: Number,
28 | score: Number
29 | });
30 |
31 | export const UserStatModel = model("userstat", userStatSchema);
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "osutracker2",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.ts",
6 | "scripts": {
7 | "start": "ts-node server.ts",
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@types/cors": "^2.8.12",
14 | "@types/express": "^4.17.13",
15 | "cors": "^2.8.5",
16 | "dotenv": "^10.0.0",
17 | "express": "^4.17.1",
18 | "mongoose": "^6.0.12",
19 | "node-osu": "^2.2.1",
20 | "prerender-node": "^3.2.5",
21 | "ts-node": "^10.4.0",
22 | "typescript": "^4.4.4"
23 | },
24 | "devDependencies": {
25 | "ts-node": "^10.4.0"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/routes/countries.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { CountryModel } from "../models/Country.model";
3 | import { UserModel } from "../models/User.model";
4 | import { CountryStatModel } from "../models/CountryStat.model";
5 | import { CountryPlaysModel } from "../models/CountryPlays.model";
6 | import { CountryPlayersModel } from "../models/CountryPlayers.model";
7 | export const countryRouter = express.Router();
8 |
9 | const parseName = (name: string) => {
10 | let query: any
11 | if (name.length == 2) {
12 | query = { abbreviation: { $regex : new RegExp(name, "i") } }
13 | } else {
14 | query = { name: { $regex : new RegExp(name, "i") } }
15 | }
16 | return query
17 | }
18 |
19 | countryRouter.route("/all").get((req, res) => {
20 | CountryModel.find()
21 | .then((countries) => res.json(countries))
22 | .catch((err) => res.status(400).json("Error: " + err));
23 | });
24 |
25 | countryRouter.route("/number").get((req, res) => {
26 | CountryModel.countDocuments().then((count) => res.json(count));
27 | });
28 |
29 | countryRouter.route("/allFilter/:country").get(async (req, res) => {
30 | const name = req.query.name as string
31 | const page = parseInt(req.query.page as string);
32 | const order = { [name]: req.query.order };
33 | const country = await CountryModel.findOne(
34 | { name: req.params.country },
35 | { abbreviation: 1 }
36 | );
37 | const countryAbbreviation = country?.abbreviation;
38 |
39 | let users = await UserModel.find(
40 | { country: countryAbbreviation },
41 | { currentTop: 0, modsCount: 0 }
42 | ).sort(order)
43 | .collation({ locale: "en_US", numericOrdering: true })
44 | .limit(50)
45 | .skip(50 * (page - 1));
46 |
47 | users.forEach((user) => {
48 | user.rank = user.rank ?? "0";
49 | user.plays = user.plays ?? "0";
50 | user.averageObjects = user?.averageObjects ?? 0;
51 | user.acc = (parseFloat(user.acc) ?? 0).toFixed(2);
52 | user.level = parseFloat((user.level ?? 0).toFixed(1))
53 | user.pp = (parseFloat(user.pp) ?? 0).toFixed(1);
54 | user.averageObjects = user.averageObjects ?? 0;
55 | user.range = (user.range == "" ? 0 : user.range ?? 0).toString();
56 | });
57 |
58 | let number = await UserModel.countDocuments();
59 | res.json({ data: users, numberResults: number });
60 | });
61 |
62 | countryRouter.route("/limitedAll").get((req, res) => {
63 | CountryModel.find(
64 | {},
65 | {
66 | name: 1,
67 | abbreviation: 1,
68 | pp: 1,
69 | acc: 1,
70 | farm: 1,
71 | range: 1,
72 | averageObjects: 1,
73 | playerWeighting: 1,
74 | }
75 | ).then((countries) => res.json(countries))
76 | .catch((err) => res.status(400).json("Error: " + err));
77 | });
78 |
79 | countryRouter.route("/:name/details").get((req, res) => {
80 | const query = parseName(req.params.name)
81 | CountryModel.findOne(query)
82 | .then((details) => {
83 | res.json(details);
84 | }).catch((err) => res.status(400).json("Error: " + err + req.params.name));
85 | });
86 |
87 | countryRouter.route("/:name/stats").get((req, res) => {
88 | const query = parseName(req.params.name)
89 | CountryStatModel.find(query)
90 | .then((stats) => {
91 | res.json(stats);
92 | }).catch((err) => res.status(400).json("Error: " + err + req.params.name));
93 | });
94 |
95 | countryRouter.route("/:name/players").get((req, res) => {
96 | const query = parseName(req.params.name)
97 | CountryPlayersModel.find(query)
98 | .then((players) => {
99 | res.json(players);
100 | }).catch((err) => res.status(400).json("Error: " + err + req.params.name));
101 | });
102 |
103 | countryRouter.route("/:name/plays").get((req, res) => {
104 | const query = parseName(req.params.name)
105 | CountryPlaysModel.find(query)
106 | .then((plays) => {
107 | res.json(plays);
108 | }).catch((err) => res.status(400).json("Error: " + err + req.params.name));
109 | });
110 |
111 | countryRouter.route("/:abbreviation").get((req, res) => {
112 | CountryModel.findOne({ abbreviation: { $regex: new RegExp(req.params.abbreviation, "i") } }, { name: 1 })
113 | .then((country) => {
114 | res.json(country?.name);
115 | }).catch((err) => res.status(400).json("Error: " + err + req.params.abbreviation));
116 | });
117 |
--------------------------------------------------------------------------------
/routes/search.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import {
3 | CountrySummary,
4 | GenericSummary,
5 | SearchRes,
6 | UserSummary,
7 | } from "../interfaces/search";
8 | import { CountryModel } from "../models/Country.model";
9 | import { UserModel } from "../models/User.model";
10 | export const searchRouter = express.Router();
11 |
12 | searchRouter.route("/all").get(async (req, res) => {
13 | const page = parseInt(req.query.page as string);
14 | const text = (req.query.text as string).replace(
15 | /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,
16 | "\\$&"
17 | );
18 | const regex = new RegExp(text, "i");
19 |
20 | let countries: CountrySummary[] = [];
21 | let users: UserSummary[] = [];
22 |
23 | const usersPromise = UserModel.find(
24 | { name: { $regex: regex } },
25 | {
26 | name: 1,
27 | id: 1,
28 | pp: 1,
29 | }
30 | );
31 |
32 | const countriesPromise = CountryModel.find(
33 | { name: { $regex: regex } },
34 | {
35 | name: 1,
36 | abbreviation: 1,
37 | pp: 1,
38 | }
39 | );
40 |
41 | await Promise.all([
42 | usersPromise.then((items) => (users = items)),
43 | countriesPromise.then((items) => (countries = items)),
44 | ]);
45 |
46 | // null PP will mess up the sort
47 | users = users.filter((user) => user.pp);
48 |
49 | const combined: GenericSummary[] = users
50 | .map((user) => {
51 | return {
52 | type: "user",
53 | id: user.id,
54 | name: user.name,
55 | pp: user.pp,
56 | };
57 | })
58 | .concat(
59 | countries.map((country) => {
60 | return {
61 | type: "country",
62 | id: country.abbreviation,
63 | name: country.name,
64 | pp: country.pp,
65 | };
66 | })
67 | );
68 |
69 | const sorted = combined.sort((a, b) => parseFloat(b.pp) - parseFloat(a.pp));
70 |
71 | const resLength = sorted.length;
72 | const resPage = sorted.slice((page - 1) * 5, page * 5);
73 |
74 | const searchRes: SearchRes = { page: resPage, length: resLength };
75 | res.json(searchRes);
76 | });
77 |
--------------------------------------------------------------------------------
/routes/stats.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { OverallStatsModel } from "../models/OverallStats.model";
3 | import { HistoricTopModel } from '../models/HistoricTop.model'
4 | import { BeatmapModel } from "../models/Beatmap.model";
5 | import { PPBarrierRes } from "../interfaces/stats";
6 | import { PPBarrier, PPBarrierModel } from "../models/PPBarrier.model";
7 | import { BeatmapCountModel } from "../models/BeatmapCount.model";
8 | export const statsRouter = express.Router();
9 |
10 | const parseValidInteger = (number: string): number => {
11 | const parsed = parseInt(number);
12 | if (isNaN(parsed)) {
13 | return 0;
14 | }
15 | return parsed;
16 | }
17 |
18 | statsRouter.route("/historicTop").get((req, res) => {
19 | HistoricTopModel.find().then(top => res.json(top))
20 | })
21 |
22 | statsRouter.route("/").get((req, res) => {
23 | OverallStatsModel.findOne({})
24 | .then((stats) => res.json(stats))
25 | .catch((err) => res.status(400).json("Error: " + err));
26 | });
27 |
28 | statsRouter.route("/farmSets").get((req, res) => {
29 | OverallStatsModel.findOne({}, { setCount: 1 })
30 | .then((stats) => {
31 | let output = (stats?.setCount??[]).slice(0, 727).map((count) => count.setId);
32 | res.json(output);
33 | })
34 | .catch((err) => res.status(400).json("Error: " + err));
35 | });
36 |
37 | statsRouter.route("/mapset/:id").get((req, res) => {
38 | BeatmapModel.findOne({ id: req.params.id })
39 | .then((map) => res.json(map))
40 | .catch((err) => res.status(400).json("Error: " + err));
41 | });
42 |
43 | statsRouter.route("/ppBarrier").get(async (req, res) => {
44 | const numberQuery = req.query?.number as string
45 | let number: number = 0
46 |
47 | const possibleNumbers: number[] = []
48 | const numbers = await PPBarrierModel.find({}, { number: 1 })
49 | for (const number of numbers) {
50 | possibleNumbers.push(number.number.valueOf())
51 | }
52 | possibleNumbers.sort((a, b) => a - b)
53 |
54 | if (numberQuery) {
55 | const validation = parseValidInteger(numberQuery)
56 | if (!validation) {
57 | res.status(400).json("Invalid number, expected non zero integer")
58 | return
59 | } else {
60 | number = validation
61 | if (!possibleNumbers.includes(number)) {
62 | res.status(400).json("Invalid number, not found in database. Possible values are " + possibleNumbers.join(", "))
63 | return
64 | }
65 | }
66 | }
67 |
68 | let barriers: PPBarrier[] = []
69 | if (number) {
70 | barriers = await PPBarrierModel.find({ number })
71 | } else {
72 | barriers = await PPBarrierModel.find({})
73 | }
74 |
75 | const output: PPBarrierRes[] = []
76 |
77 | for (const barrier of barriers) {
78 | const list: { name: string, id: string, count: number }[] = []
79 | const number = barrier.number
80 | const limit = barrier.list.slice(0, 100)
81 | for (const count of limit) {
82 | const beatmap = await BeatmapModel.findOne({ id: count.setId })
83 | if (beatmap) {
84 | list.push({ name: beatmap.name, id: count.setId, count: count.count })
85 | }
86 | }
87 | output.push({ number: number.valueOf(), list })
88 | }
89 |
90 | if (number) {
91 | res.json(output.pop())
92 | } else {
93 | res.json(output)
94 | }
95 | })
96 |
97 | statsRouter.route("/idCounts").get(async (req, res) => {
98 | const limit = req.query?.limit as string
99 | const offset = req.query?.offset as string
100 | let limitNum: number = 100
101 | let offsetNum: number = 0
102 |
103 | if (limit) {
104 | const validation = parseValidInteger(limit)
105 | if (!validation) {
106 | res.status(400).json("Invalid limit, expected non zero integer")
107 | return
108 | } else {
109 | limitNum = validation
110 | }
111 | }
112 |
113 | if (offset) {
114 | const validation = parseValidInteger(offset)
115 | if (!validation) {
116 | res.status(400).json("Invalid offset, expected non zero integer")
117 | return
118 | } else {
119 | offsetNum = validation
120 | }
121 | }
122 |
123 | if (!limit) {
124 | limitNum = await BeatmapCountModel.countDocuments()
125 | }
126 |
127 | const counts = await BeatmapCountModel
128 | .find({}, { _id: 0, id: 1, count: 1 })
129 | .sort({ count: -1 })
130 | .skip(offsetNum)
131 | .limit(limitNum)
132 |
133 | res.json(counts)
134 | })
135 |
136 | statsRouter.route("/idCount/:id").get(async (req, res) => {
137 | // get number from id param
138 | const id = req.params.id as string
139 | const number = parseInt(id)
140 | if (isNaN(number)) {
141 | res.status(400).json("Invalid id, expected non zero integer")
142 | return
143 | }
144 |
145 | const count = await BeatmapCountModel.findOne({ id: number })
146 | res.json(count?.count??0)
147 | })
148 |
149 | statsRouter.route("/mapsets").get(async (req, res) => {
150 | let result = [];
151 | const sets = req.query.arr as string[]
152 |
153 | for (const setId of sets) {
154 | let beatmap = await BeatmapModel.findOne({ setId: setId }, { name: 1, mapper: 1 });
155 | result.push(beatmap);
156 | }
157 |
158 | res.json(result);
159 | });
160 |
--------------------------------------------------------------------------------
/routes/users.ts:
--------------------------------------------------------------------------------
1 | import { OverallStatsModel } from './../models/OverallStats.model';
2 | import express from "express";
3 | import { UserModel } from "../models/User.model";
4 | import { UserStatModel } from "../models/UserStat.model";
5 | import { UserPlaysModel } from "../models/UserPlays.model";
6 | import { CountryModel } from "../models/Country.model";
7 | import osu from "node-osu";
8 | import dotenv from "dotenv";
9 | import { BeatmapModel } from '../models/Beatmap.model';
10 | dotenv.config();
11 |
12 | const osuApi = new osu.Api(process.env.OSU_API_KEY??"", {
13 | // baseUrl: sets the base api url (default: https://osu.ppy.sh/api)
14 | notFoundAsError: false, // Throw an error on not found instead of returning nothing. (default: true)
15 | completeScores: false, // When fetching scores also fetch the beatmap they are for (Allows getting accuracy) (default: false)
16 | parseNumeric: false, // Parse numeric values into numbers/floats, excluding ids
17 | });
18 |
19 | export const userRouter = express.Router();
20 |
21 | userRouter.route("/limitedAll").get((req, res) => {
22 | UserModel.find({}, { name: 1, pp: 1, acc: 1, farm: 1, range: 1 })
23 | .then((users) => {
24 | res.json(users);
25 | })
26 | .catch((err) => res.status(400).json("Error: " + err));
27 | });
28 |
29 | userRouter.route("/topUserIds").get((req, res) => {
30 | UserModel.find({}, { id: 1 })
31 | .sort({ pp: "desc" })
32 | .collation({ locale: "en_US", numericOrdering: true })
33 | .limit(10)
34 | .then((users) => {
35 | res.json(users);
36 | })
37 | .catch((err) => res.status(400).json("Error: " + err));
38 | });
39 |
40 | userRouter.route("/allFilter").get(async (req, res) => {
41 | const page = parseInt(req.query.page as string);
42 | const name = req.query.name as string
43 | const order = { [name]: req.query.order };
44 |
45 | let users = await UserModel.find({}, { currentTop: 0, modsCount: 0 })
46 | .sort(order)
47 | .collation({ locale: "en_US", numericOrdering: true })
48 | .limit(50)
49 | .skip(50 * (page - 1));
50 |
51 | users.forEach((user) => {
52 | user.rank = user.rank ?? "0";
53 | user.plays = user.plays ?? "0";
54 | user.averageObjects = user?.averageObjects ?? 0;
55 | user.acc = (parseFloat(user.acc) ?? 0).toFixed(2);
56 | user.level = parseFloat((user.level ?? 0).toFixed(1))
57 | user.pp = (parseFloat(user.pp) ?? 0).toFixed(1);
58 | user.averageObjects = user.averageObjects ?? 0;
59 | user.range = (user.range == "" ? 0 : user.range ?? 0).toString();
60 | });
61 |
62 | let number = await UserModel.countDocuments();
63 |
64 | res.json({ data: users, numberResults: number });
65 | });
66 |
67 | userRouter.route("/number").get((req, res) => {
68 | UserModel.countDocuments().then((count) => res.json(count));
69 | });
70 |
71 | userRouter.route("/limitedAllCountry/:country").get(async (req, res) => {
72 | let abbreviation = (
73 | await CountryModel.findOne({ name: req.params.country }, { abbreviation: 1 })
74 | )?.abbreviation;
75 |
76 | UserModel.find(
77 | { country: abbreviation },
78 | {
79 | name: 1,
80 | id: 1,
81 | pp: 1,
82 | rank: 1,
83 | acc: 1,
84 | farm: 1,
85 | range: 1,
86 | joined: 1,
87 | level: 1,
88 | averageObjects: 1,
89 | }
90 | )
91 | .then((users) => res.json(users))
92 | .catch((err) => res.status(400).json("Error: " + err));
93 | });
94 |
95 | userRouter.route("/all").get((req, res) => {
96 | UserModel.find()
97 | .then((users) => res.json(users))
98 | .catch((err) => res.status(400).json("Error: " + err));
99 | });
100 |
101 | userRouter.route("/:name/getId").get((req, res) => {
102 | UserModel.findOne({ name: req.params.name }, { id: 1 })
103 | .then((user) => {
104 | res.json(user?.id);
105 | })
106 | .catch((err) => res.status(400).json("Error: " + err + req.params.name));
107 | });
108 |
109 | userRouter.route("/:id/getName").get((req, res) => {
110 | UserModel.findOne({ id: req.params.id }, { name: 1 })
111 | .then((user) => {
112 | res.json(user?.name);
113 | })
114 | .catch((err) => res.status(400).json("Error: " + err + req.params.id));
115 | });
116 |
117 | userRouter.route("/:id").get((req, res) => {
118 | UserModel.findOne({ id: req.params.id })
119 | .then((user) => {
120 | res.json(user);
121 | })
122 | .catch((err) => res.status(400).json("Error: " + err + req.params.id));
123 | });
124 |
125 | userRouter.route("/:id/stats").get((req, res) => {
126 | UserStatModel.find({ id: req.params.id })
127 | .then((stats) => {
128 | res.json(stats);
129 | })
130 | .catch((err) => res.status(400).json("Error: " + err + req.params.id));
131 | });
132 |
133 | userRouter.route("/:id/plays").get((req, res) => {
134 | UserPlaysModel.find({ id: req.params.id })
135 | .then((plays) => {
136 | res.json(plays);
137 | })
138 | .catch((err) => res.status(400).json("Error: " + err + req.params.id));
139 | });
140 |
141 | userRouter.route("/add").post(async (req, res) => {
142 | const checkExists = await UserModel.findOne({ name: req.body.name });
143 | if (checkExists) {
144 | res.status(409).json()
145 | return;
146 | }
147 |
148 | await osuApi.getUser({ u: req.body.name }).then(async (user) => {
149 | if (user) {
150 | const farmSets = await OverallStatsModel.findOne({}, { setCount: 1 })
151 | const scores = await osuApi.getUserBest({ u: req.body.name, limit: 100 })
152 | let farm = 0
153 |
154 | for (const score of scores) {
155 | if (score.beatmapId && farmSets) {
156 | const map = await BeatmapModel.findOne({ id: score.beatmapId as string })
157 | if (map) {
158 | if (farmSets.setCount.find(x => x.setId == map.setId)) {
159 | farm += 1
160 | }
161 | }
162 | }
163 | }
164 |
165 | await new UserModel({
166 | name: user.name,
167 | id: user.id,
168 | url: "http://s.ppy.sh/a/" + user.id,
169 | country: user.country,
170 | pp: user.pp.raw,
171 | rank: user.pp.rank,
172 | acc: user.accuracy,
173 | plays: user.counts.plays,
174 | level: user.level,
175 | range: scores.length ? scores[0].pp - scores[scores.length-1].pp : null,
176 | joined: (user.joinDate as Date).getTime(),
177 | currentTop: [],
178 | farm: farm,
179 | }).save()
180 |
181 | await new UserStatModel({
182 | id: user.id,
183 | pp: user.pp.raw,
184 | rank: user.pp.rank,
185 | acc: user.accuracy,
186 | plays: user.counts.plays,
187 | score: user.scores.total,
188 | countryRank: user.pp.countryRank,
189 | range: scores.length ? scores[0].pp - scores[scores.length-1].pp : null,
190 | level: user.level,
191 | date: new Date().getTime()
192 | }).save()
193 |
194 | res.status(201).json()
195 | }
196 | }).catch(err => res.status(404).json());
197 | });
198 |
--------------------------------------------------------------------------------
/server.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import mongoose from "mongoose";
3 | import path from "path";
4 | import cors from "cors";
5 | import dotenv from "dotenv";
6 | import { userRouter } from "./routes/users";
7 | import { countryRouter } from "./routes/countries";
8 | import { statsRouter } from "./routes/stats";
9 | import { searchRouter } from "./routes/search";
10 | dotenv.config();
11 |
12 | const app = express();
13 | const PORT = process.env.PORT || 8080; // Step 1
14 |
15 | mongoose.connect(process.env.ATLAS??"", {});
16 |
17 | mongoose.connection.on("connected", () => {
18 | console.log("Mongoose is connected!!!!");
19 | });
20 |
21 | // Data parsing
22 | app.use(require('prerender-node').set("prerenderToken", process.env.PRERENDER??""));
23 | app.use(express.json());
24 | app.use(express.urlencoded({ extended: false }));
25 | app.use(cors({ credentials: true, origin: true }));
26 |
27 | app.use(function (req, res, next) {
28 | res.header("Access-Control-Allow-Origin", "*");
29 | res.header(
30 | "Access-Control-Allow-Headers",
31 | "Origin, X-Requested-With, Content-Type, Accept"
32 | );
33 | next();
34 | });
35 |
36 | if (process.env.NODE_ENV === "production") {
37 | app.use(express.static("client/build"));
38 | }
39 |
40 | app.use("/api/users", userRouter);
41 | app.use("/api/countries", countryRouter);
42 | app.use("/api/stats", statsRouter);
43 | app.use("/api/search", searchRouter);
44 |
45 | app.get("/sitemap.xml", function (req, res) {
46 | res.sendFile(path.join(__dirname, "client/build/sitemap.xml"));
47 | });
48 |
49 | app.get("/favicon.ico", function (req, res) {
50 | res.sendFile(path.join(__dirname, "client/build/favicon.ico"));
51 | });
52 |
53 | app.get("/*", function (req, res) {
54 | res.sendFile(path.join(__dirname, "client/build/index.html"), function (err) {
55 | if (err) {
56 | res.status(500).send(err);
57 | }
58 | });
59 | });
60 |
61 | app.listen(PORT, () => console.log(`Server is starting at ${PORT}`));
62 |
--------------------------------------------------------------------------------