From 86a58c644cfcecc0cd804aa6be3955751a5d3f5f Mon Sep 17 00:00:00 2001 From: Josh Taylor Date: Sun, 1 Sep 2024 19:33:44 -0600 Subject: [PATCH] calculate users in the frontend --- src/App.tsx | 3 +- src/components/Leaderboard.tsx | 47 ++++++--- src/components/LeaderboardRow.tsx | 127 +++++++++------------- src/hooks/UseProblem.tsx | 31 ++++++ src/hooks/UseUser.tsx | 29 ++--- src/hooks/UseWeek.tsx | 1 + src/hooks/base.tsx | 2 +- src/pages/Profile.tsx | 2 +- src/pages/SignIn.tsx | 2 +- src/score/score.ts | 169 ++++++++++++++++++++++++++++++ src/types/platform.ts | 19 +--- 11 files changed, 299 insertions(+), 133 deletions(-) create mode 100644 src/hooks/UseProblem.tsx create mode 100644 src/score/score.ts diff --git a/src/App.tsx b/src/App.tsx index ed06afd..a95aab4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,12 +26,11 @@ const config = { authDomain: "byu-cpc.firebaseapp.com", }; firebase.initializeApp(config); -export const BACKEND_URL = "https://byu-cpc-backend-tqxfeezgfa-uw.a.run.app"; const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week - staleTime: 1000 * 60, + staleTime: 1000, persister: experimental_createPersister({ buster: "1.0.1", storage: createIdbStorage(), diff --git a/src/components/Leaderboard.tsx b/src/components/Leaderboard.tsx index 35a0ba7..428f664 100644 --- a/src/components/Leaderboard.tsx +++ b/src/components/Leaderboard.tsx @@ -4,6 +4,10 @@ import "./Leaderboard.css"; import { useUsers } from "../hooks/UseUser"; import { LeaderboardRow } from "./LeaderboardRow"; import { useThisWeek } from "../hooks/UseWeek"; +import { useSearchParams } from "react-router-dom"; +import { useAllStudyProblems, useProblems } from "../hooks/UseProblem"; +import { useQueries } from "@tanstack/react-query"; +import { getStats } from "../score/score"; function formatCodeforcesId(input: string) { const match = input.match(/^(\d+)(\D.*)$/); @@ -34,15 +38,32 @@ export function Leaderboard() { solvedProblems.codeforces.set(problem, 1); } } - for (const user of users) { - for (const problem of user.kattis_data) { - solvedProblems.kattis.set(problem.id, 2); + const links = thisWeek.links ?? {}; + const [params] = useSearchParams(); + const leaderboard = params.get("leaderboard") || "all"; + const { data: allProblems } = useProblems(); + const { data: allStudyProblems } = useAllStudyProblems(); + const calculatedUsers = useQueries({ + queries: users.map((user) => ({ + queryKey: [leaderboard, "score", user.id], + queryFn: async () => { + return allProblems && allStudyProblems + ? getStats(user, allProblems, allStudyProblems) + : null; + }, + enabled: !!allProblems && !!allStudyProblems, + staleTime: 1000 * 60 * 5, + })), + combine: (results) => results.map((r) => r.data).filter((a) => !!a), + }); + for (const user of calculatedUsers) { + for (const problem of user.solvedDuringContest["kattis"]) { + solvedProblems.kattis.set(problem, 2); } - for (const problem of user.cf_data.problems) { - solvedProblems.codeforces.set(problem.id, 2); + for (const problem of user.solvedDuringContest["codeforces"]) { + solvedProblems.codeforces.set(problem, 2); } } - const links = thisWeek.links ?? {}; return (
{thisWeek.topic && ( @@ -57,7 +78,9 @@ export function Leaderboard() { {Object.keys(links) .sort() .map((key) => ( - {key} + + {key} + ))}
@@ -109,15 +132,15 @@ export function Leaderboard() { )} - {users - .filter((a) => !!a.score || a.id === user?.uid) + {calculatedUsers + .filter((a) => !!a.score || a.user.id === user?.uid) .sort((a, b) => b.score - a.score) .map((u, i) => ( ))} diff --git a/src/components/LeaderboardRow.tsx b/src/components/LeaderboardRow.tsx index 0b014ae..9f4ae77 100644 --- a/src/components/LeaderboardRow.tsx +++ b/src/components/LeaderboardRow.tsx @@ -3,8 +3,8 @@ import Flame from "../icons/Flame"; import FlameBorder from "../icons/FlameBorder"; import DeadFlame from "../icons/DeadFlame"; import React from "react"; -import { User } from "../hooks/UseUser"; import { useThisWeek } from "../hooks/UseWeek"; +import { UserStats } from "../score/score"; function WeeklyProblemBox({ solved, allProblemsLength, @@ -23,24 +23,25 @@ function WeeklyProblemBox({ ); } +function numberWithCommas(x: number) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} type LeaderboardRowProps = { - user: User; + userStats: UserStats; rank: number; isMe: boolean; }; -export function LeaderboardRow({ user, rank, isMe }: LeaderboardRowProps) { +export function LeaderboardRow({ userStats, rank, isMe }: LeaderboardRowProps) { + const userId = userStats.user.id; const thisWeek = useThisWeek(); const allProblemsLength = thisWeek.kattis.length + thisWeek.codeforces.length; - const solvedKattis = new Set(Object.keys(user.kattis_submissions)); - const solvedCodeforces = new Set(Object.keys(user.codeforces_submissions)); - const validSolvedKattis = new Set( - user.kattis_data.map((problem) => problem.id) - ); - const validSolvedCodeforces = new Set( - user.cf_data.problems.map((problem) => problem.id) + const solvedKattis = new Set(Object.keys(userStats.user.kattis_submissions)); + const solvedCodeforces = new Set( + Object.keys(userStats.user.codeforces_submissions) ); + return (
@@ -50,7 +51,7 @@ export function LeaderboardRow({ user, rank, isMe }: LeaderboardRowProps) {
- {rank}. {user.display_name} + {rank}.{" "} + {userStats.user.display_name}
- {user.cur_streak > 0 && ( + {userStats.streak.currentStreak > 0 && (
- {user.cur_streak} + {userStats.streak.currentStreak}
)} - {user.is_active ? ( + {userStats.streak.isActive ? ( - ) : user.cur_streak ? ( + ) : userStats.streak.currentStreak ? ( ) : ( @@ -106,111 +108,78 @@ export function LeaderboardRow({ user, rank, isMe }: LeaderboardRowProps) {
Lv.{" "} - {user.level} + {userStats.level.level}
Score:{" "} - {user.score} + {numberWithCommas(userStats.score)}
-
{user.days.length} days
-
{user.cf_data.contests.length} contests
-
- {user.cf_data.problems.length + user.kattis_data.length} problems -
+
{userStats.exp.size} days
+
{userStats.contests.size} contests
+
{userStats.problemCount} problems
Avg. Diff.
- {!!user.kattis_data.length && ( + {!!userStats.avgDifficulty["kattis"] && (
Kattis
-
- {Math.round( - (user.kattis_data.reduce((a, b) => a + b.difficulty, 0) / - user.kattis_data.length) * - 10 - ) / 10} -
+
{userStats.avgDifficulty["kattis"]}
)} - {!!user.cf_data.problems.length && ( + {!!userStats.avgDifficulty["codeforces"] && (
Codeforces
- {Math.round( - user.cf_data.problems.reduce( - (a, b) => a + b.difficulty, - 0 - ) / user.cf_data.problems.length - )} + {userStats.avgDifficulty["codeforces"]}
)}
-
+
- +
-
Current Streak: {user.cur_streak}
-
Best Streak: {user.max_streak}
+
Current Streak: {userStats.streak.currentStreak}
+
Best Streak: {userStats.streak.maximumStreak}
- - {user.cur_exp} / {user.cur_exp + user.next_level} XP + + {userStats.level.currentExp} /{" "} + {userStats.level.currentExp + userStats.level.nextLevel} XP - +
-
- Avg difficulty:{" "} - {Math.round( - (user.kattis_data.reduce((a, b) => a + b.difficulty, 0) / - user.kattis_data.length) * - 10 - ) / 10} -
-
- Max difficulty:{" "} - {Math.max( - ...user.kattis_data.map((problem) => problem.difficulty) - )} -
+
Avg difficulty: {userStats.avgDifficulty["kattis"]}
+
Max difficulty: {userStats.maxDifficulty["kattis"]}
- +
-
- Avg difficulty:{" "} - {Math.round( - user.cf_data.problems.reduce((a, b) => a + b.difficulty, 0) / - user.cf_data.problems.length - )} -
-
- Max difficulty:{" "} - {Math.max( - ...user.cf_data.problems.map((problem) => problem.difficulty) - )} -
+
Avg difficulty: {userStats.avgDifficulty["codeforces"]}
+
Max difficulty: {userStats.maxDifficulty["codeforces"]}
diff --git a/src/hooks/UseProblem.tsx b/src/hooks/UseProblem.tsx new file mode 100644 index 0000000..e5ebb17 --- /dev/null +++ b/src/hooks/UseProblem.tsx @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { BACKEND_URL } from "./base"; +import { Platform } from "../types/platform"; +type Problem = { [key: string]: { rating: number; name: string } }; +export type AllProblems = Record; + +export type StudyProblems = Record; + +async function getAllProblems(): Promise { + return (await axios.get(`${BACKEND_URL}/get_all_problems`)).data; +} + +export const useProblems = () => { + return useQuery({ + queryFn: getAllProblems, + queryKey: ["allProblems"], + staleTime: 1000 * 60 * 60 * 24, + }); +}; + +async function getAllStudyProblems(): Promise { + return (await axios.get(`${BACKEND_URL}/get_all_study_problems`)).data; +} + +export const useAllStudyProblems = () => { + return useQuery({ + queryFn: getAllStudyProblems, + queryKey: ["allStudyProblems"], + }); +}; diff --git a/src/hooks/UseUser.tsx b/src/hooks/UseUser.tsx index 553ec61..af733c0 100644 --- a/src/hooks/UseUser.tsx +++ b/src/hooks/UseUser.tsx @@ -1,33 +1,20 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { BACKEND_URL } from "../App"; -import { - CodeforcesContest, - CodeforcesProblem, - KattisProblem, -} from "../types/platform"; +import { BACKEND_URL } from "./base"; export type User = { kattis_username: string; codeforces_username: string; display_name: string; id: string; - kattis_data: KattisProblem[]; - kattis_submissions: { [key: string]: number }; - cf_data: { problems: CodeforcesProblem[]; contests: CodeforcesContest[] }; - codeforces_submissions: { [key: string]: { type: string; time: number } }; - cur_streak: number; - max_streak: number; - days: number[]; - exp: { [key: number]: number }; - score: number; - is_active: boolean; - level: number; - next_level: number; - cur_exp: number; + kattis_submissions: Record; + codeforces_submissions: Record< + string, + { type: "practice" | "contestant" | "virtual"; time: number } + > & { contests: Record }; }; async function getUsers(): Promise { - return (await axios.get(`${BACKEND_URL}/get_table`)).data; + return (await axios.get(`${BACKEND_URL}/get_users`)).data; } export const useUsers = () => { @@ -35,6 +22,7 @@ export const useUsers = () => { const query = useQuery({ queryFn: getUsers, queryKey: ["users"], + staleTime: 1000 * 60 * 5, }); if (query.data) { for (const row of query.data) { @@ -43,5 +31,6 @@ export const useUsers = () => { }); } } + console.log(query.data); return query.data ?? []; }; diff --git a/src/hooks/UseWeek.tsx b/src/hooks/UseWeek.tsx index ddd7508..de77464 100644 --- a/src/hooks/UseWeek.tsx +++ b/src/hooks/UseWeek.tsx @@ -18,6 +18,7 @@ export const useThisWeek = () => { queryKey: ["this_week"], queryFn: getThisWeek, refetchOnWindowFocus: true, + staleTime: 1000 * 60 * 5, }); const data = weekQuery.data; if (!data) return { codeforces: [], kattis: [], topic: "", start: "" }; diff --git a/src/hooks/base.tsx b/src/hooks/base.tsx index d72efd9..3c6be07 100644 --- a/src/hooks/base.tsx +++ b/src/hooks/base.tsx @@ -1 +1 @@ -export const BACKEND_URL = "https://byu-cpc-backend-tqxfeezgfa-uw.a.run.app"; +export const BACKEND_URL = "http://localhost:5000"; diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 03494ad..644ce78 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -5,8 +5,8 @@ import { useUserProfile } from "../hooks/UseProfile"; import useUser from "../hooks/UseProfile"; import { useDebounce } from "use-debounce"; import axios from "axios"; -import { BACKEND_URL } from "../App"; import { useMutation } from "@tanstack/react-query"; +import { BACKEND_URL } from "../hooks/base"; async function isPlatformUsernameValid(username: string, platform: string) { if (!username) { diff --git a/src/pages/SignIn.tsx b/src/pages/SignIn.tsx index 183d495..3b4f8f6 100644 --- a/src/pages/SignIn.tsx +++ b/src/pages/SignIn.tsx @@ -12,7 +12,7 @@ import { setPersistence, browserLocalPersistence, } from "firebase/auth"; -import { BACKEND_URL } from "../App"; +import { BACKEND_URL } from "../hooks/base"; function LogIn() { const navigate = useNavigate(); diff --git a/src/score/score.ts b/src/score/score.ts new file mode 100644 index 0000000..b30de76 --- /dev/null +++ b/src/score/score.ts @@ -0,0 +1,169 @@ +import { AllProblems, StudyProblems } from "../hooks/UseProblem"; +import { User } from "../hooks/UseUser"; +import { Platform, platformValues } from "../types/platform"; + +const START = new Date("2024-05-21T00:00:00.0"); +const END = new Date("2024-09-12T18:00:00.0"); +const DIFFICULTY_EXPO = 1.2; +const CONTEST_BONUS = 100; +const DAILY_BONUS = 10; +const getDayFromDate = (date: Date) => { + return Math.floor((date.getTime() - START.getTime()) / (1000 * 60 * 60 * 24)); +}; + +const kattisDifficultyToExp = (difficulty: number) => { + return Math.pow(difficulty, DIFFICULTY_EXPO) * 10; +}; + +const codeforcesDifficultyToExp = (difficulty: number) => { + return kattisDifficultyToExp(((1 / 25) * difficulty - 17) / 10); +}; + +const expMap: Record number> = { + codeforces: codeforcesDifficultyToExp, + kattis: kattisDifficultyToExp, +}; +const fallbackDifficulty = { + kattis: 1.1, + codeforces: 800, +} as const satisfies Record; + +const get_weekday_from_day = (day: number) => { + return new Date(START.getTime() + day * 1000 * 60 * 60 * 24).getDay(); +}; + +const skippable = (day: number) => { + return get_weekday_from_day(day) === 0; +}; + +const getCurrentStreak = (days: Set) => { + let today = getDayFromDate(new Date()); + let count = days.has(today) ? 1 : 0; + today--; + while (days.has(today) || skippable(today)) { + if (days.has(today)) count++; + today--; + } + return count; +}; + +const getMaximumStreak = (days: number[]) => { + const daysSet = new Set(days); + days.sort((a, b) => a - b); + let best = 0; + let cur = 0; + for (const i of days) { + if (daysSet.has(i - 1) || (daysSet.has(i - 2) && skippable(i - 1))) { + cur++; + } else { + cur = 1; + } + best = Math.max(best, cur); + } + return best; +}; + +const getStreaks = (days: number[]) => { + return { + currentStreak: getCurrentStreak(new Set(days)), + maximumStreak: getMaximumStreak(days), + isActive: days.includes(getDayFromDate(new Date())), + }; +}; + +const getExpFromLevel = (level: number) => { + return level * 100 + (((level - 1) * level) / 2) * 5; +}; + +const getLevel = (score: number) => { + const level = Math.floor((-195 + Math.sqrt(38025 + 40 * score)) / 10); + const nextLevel = Math.round(getExpFromLevel(level + 1) - score); + const currentExp = Math.round(score - getExpFromLevel(level)); + return { level, nextLevel, currentExp }; +}; + +export function getStats( + user: User, + allProblems: AllProblems, + studyProblems: StudyProblems +) { + const kattisSubmissions: Record = + Object.keys(user.kattis_submissions).reduce( + (acc, key) => ({ + ...acc, + [key]: { type: "practice", time: user.kattis_submissions[key] }, + }), + {} + ); + const all_submissions = { + codeforces: user.codeforces_submissions, + kattis: kattisSubmissions, + }; + let problemCount = 0; + const exp = new Map(); + const contests = new Set(); + const difficultyTotal = { kattis: 0, codeforces: 0 }; + const difficultyCount = { kattis: 0, codeforces: 0 }; + const maxDifficulty = { kattis: 0, codeforces: 0 }; + const avgDifficulty = { kattis: 0, codeforces: 0 }; + const solvedDuringContest = { + kattis: new Set(), + codeforces: new Set(), + }; + for (const platform of platformValues) { + for (const [problemId, submission] of Object.entries( + all_submissions[platform] + )) { + if ( + problemId === "contests" || + submission.time < START.getTime() / 1000 || + submission.time > END.getTime() / 1000 + ) { + continue; + } + solvedDuringContest[platform].add(problemId); + problemCount += 1; + const difficulty = + allProblems[platform][problemId]?.rating ?? + fallbackDifficulty[platform]; + difficultyTotal[platform] += difficulty; + difficultyCount[platform] += 1; + maxDifficulty[platform] = Math.max(maxDifficulty[platform], difficulty); + const day = getDayFromDate(new Date(submission.time * 1000)); + const current_exp = exp.get(day) ?? DAILY_BONUS; + const multiplier = + submission.type === "contestant" || + studyProblems[platform].includes(problemId) + ? 2 + : 1; + if (submission.type === "contestant") { + contests.add(day); + } + exp.set(day, current_exp + expMap[platform](difficulty) * multiplier); + } + avgDifficulty[platform] = + Math.round((difficultyTotal[platform] / difficultyCount[platform]) * 10) / + 10; + } + const score = Math.round( + Array.from(exp.values()).reduce((acc, val) => acc + val, 0) + + contests.size * CONTEST_BONUS + ); + const level = getLevel(score); + const streak = getStreaks([...exp.keys()]); + + return { + exp, + contests, + avgDifficulty, + maxDifficulty, + score, + user, + problemCount, + solvedDuringContest, + level, + streak, + }; +} + +export type UserStats = ReturnType; diff --git a/src/types/platform.ts b/src/types/platform.ts index 8e27c22..6d4d376 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -1,17 +1,2 @@ -export enum Platform { - kattis, - codeforces, -} - -export type KattisProblem = { - id: string; - timestamp: number; - difficulty: number; -}; - -export type CodeforcesProblem = KattisProblem & { type: string }; - -export type CodeforcesContest = { - id: string; - timestamp: number; -}; +export const platformValues = ["kattis", "codeforces"] as const; +export type Platform = (typeof platformValues)[number];