Elo calculator

This commit is contained in:
Timothy Armes
2023-09-14 14:46:13 +02:00
parent ac467d7c38
commit 2d0c006446
9 changed files with 277 additions and 18 deletions
+8 -1
View File
@@ -1,6 +1,6 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next-intl/link"; import Link from "next-intl/link";
import { FaGithub, FaRegEnvelope } from "react-icons/fa"; import { FaGithub, FaInfoCircle, FaRegEnvelope } from "react-icons/fa";
import ThemeSwitcher from "./ThemeSwitcher"; import ThemeSwitcher from "./ThemeSwitcher";
@@ -25,6 +25,13 @@ export default function Footer() {
> >
<FaGithub /> <FaGithub />
</Link> </Link>
<Link
href="/qui-sommes-nous"
aria-label={t("contactAria")}
className="mr-4"
>
<FaInfoCircle />
</Link>
<Link <Link
href="/contactez-nous" href="/contactez-nous"
aria-label={t("contactAria")} aria-label={t("contactAria")}
+2 -10
View File
@@ -72,18 +72,10 @@ const HamburgerMenu = ({
</li> </li>
<li className="py-5 text-center text-xl"> <li className="py-5 text-center text-xl">
<Link <Link
href="/qui-sommes-nous" href="/elo"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white" className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
> >
{t("about")} {t("elo")}
</Link>
</li>
<li className="py-5 text-center text-xl">
<Link
href="/contactez-nous"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
>
{t("contact")}
</Link> </Link>
</li> </li>
</ul> </ul>
+1 -2
View File
@@ -8,8 +8,7 @@ export default function Navbar() {
const links = [ const links = [
{ title: t("tournaments"), route: "/tournois" }, { title: t("tournaments"), route: "/tournois" },
{ title: t("about"), route: "/qui-sommes-nous" }, { title: t("elo"), route: "/elo" },
{ title: t("contact"), route: "/contactez-nous" },
]; ];
return ( return (
+1 -1
View File
@@ -23,7 +23,7 @@ const ThemeSwitcher = () => {
className="inline-block h-4 w-4" className="inline-block h-4 w-4"
> >
<path <path
fill-rule="evenodd" fillRule="evenodd"
d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z"
clipRule="evenodd" clipRule="evenodd"
/> />
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { de } from "date-fns/locale";
import { isEmpty, isNil, isNumber } from "lodash";
import { useTranslations } from "next-intl";
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
import { IoAdd, IoCloseOutline } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
const getNewRating = (
rating: number,
opponentRating: number,
result: 1 | 0 | 0.5,
kFactor: number,
) => {
const myChanceToWin = 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
const delta =
Math.round((kFactor * (result - myChanceToWin) + Number.EPSILON) * 100) /
100;
return {
delta,
newRating: Math.round((rating + delta + Number.EPSILON) * 100) / 100,
};
};
const resultsSchema = z.object({
currentElo: z.number().int().positive(),
kFactor: z.enum(["40", "30", "20", "15", "10"]),
games: z.array(
z.object({
opponentElo: z.number().int().positive(),
result: z.enum(["win", "draw", "loss"]),
}),
),
});
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
type EloFormValues = DeepPartial<z.infer<typeof resultsSchema>>;
export default function Elo() {
const t = useTranslations("Elo");
const form = useForm<EloFormValues>({
resolver: zodResolver(resultsSchema),
defaultValues: {
kFactor: "20",
games: [{}],
},
});
const {
fields: gameFields,
append: appendGame,
remove: removeGame,
} = useFieldArray({
control: form.control,
name: "games",
});
const onSubmit = async (data: EloFormValues) => {};
const [currentElo, kFactor, games] = form.watch([
"currentElo",
"kFactor",
"games",
]);
type Deltas = {
rating: number;
deltas: { rating: number; delta: number | undefined }[];
};
const calculations = !Number.isNaN(currentElo)
? (games ?? []).reduce<Deltas>(
(acc, game) => {
if (!Number.isNaN(game?.opponentElo) && game?.result) {
const result =
game?.result === "win" ? 1 : game?.result === "loss" ? 0 : 0.5;
const { delta, newRating } = getNewRating(
acc.rating,
game.opponentElo!,
result,
parseInt(kFactor!, 10),
);
return {
rating: newRating,
deltas: [...acc.deltas, { rating: newRating, delta }],
};
}
return {
rating: acc.rating,
deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }],
};
},
{ rating: currentElo!, deltas: [] },
)
: { rating: currentElo!, deltas: [] };
const deltas = calculations.deltas;
return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="max-w-xl px-4 py-8 lg:py-16">
<h2
className="mb-10 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2"
>
{t("title")}
</h2>
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400">
{t("info")}
</p>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<TextField
name="currentElo"
label={t("currentEloLabel")}
placeholder={t("currentEloPlaceholder")}
type="number"
required
/>
<SelectField
name="kFactor"
label={t("kFactorLabel")}
options={["40", "20", "10"].map((k) => ({
value: k,
label: k,
}))}
required
/>
</div>
<h3 className="my-4 text-lg text-gray-900 dark:text-white">
{t("resultsTitle")}
</h3>
<div className="flex w-full flex-col gap-6 sm:gap-2">
{gameFields.map((game, i) => {
return (
<div key={i} className="flex w-full flex-col">
<div key={i} className="flex w-full items-center space-x-2">
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<TextField
name={`games.${i}.opponentElo`}
placeholder={t("opponentEloPlaceholder")}
type="number"
required
/>
<SelectField
name={`games.${i}.result`}
placeholder={t("gameResultPlaceholder")}
options={["win", "draw", "loss"].map((result) => ({
value: result,
label: t("gameResult", { result }),
}))}
required
/>
</div>
{gameFields.length > 1 && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-600 dark:hover:bg-neutral-700"
type="button"
onClick={() => removeGame(i)}
>
<IoCloseOutline className="h-6 w-6 text-gray-900 transition-all duration-200 hover:text-primary dark:text-neutral-400 hover:dark:text-white" />
</button>
)}
</div>
{deltas[i]?.delta !== undefined && (
<div
className={twMerge(
"mt-2 flex justify-end text-gray-900 dark:text-neutral-400",
gameFields.length > 1 && "mr-10",
i === deltas.length - 1 && "font-bold",
)}
>
{Math.round(deltas[i]!.rating)} (
<span
className={twMerge(
deltas[i].delta! > 0 && "text-success",
deltas[i].delta! < 0 && "text-error",
)}
>
{deltas[i]!.delta! >= 0 ? "+" : ""} {deltas[i].delta!}
</span>
)
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 flex justify-end">
<button
onClick={() => {
appendGame({});
}}
type="button"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
<IoAdd className="mr-2 inline-block h-5 w-5" />
{t("addGameButton")}
</button>
</div>
</form>
</FormProvider>
<div></div>
</div>
</section>
);
}
+15 -2
View File
@@ -19,8 +19,7 @@
"Nav": { "Nav": {
"title": "Échecs France", "title": "Échecs France",
"tournaments": "Tournaments", "tournaments": "Tournaments",
"about": "About Us", "elo": "Elo Calculator"
"contact": "Contact"
}, },
"Footer": { "Footer": {
@@ -119,5 +118,19 @@
"clearForm": "Clear Form", "clearForm": "Clear Form",
"success": "Thank you for your message.", "success": "Thank you for your message.",
"failure": "Sorry, something went wrong. Please try again." "failure": "Sorry, something went wrong. Please try again."
},
"Elo": {
"title": "Elo Calculator",
"info": "Use this calculator to give yourself an idea of your new Elo rating after a tournament.",
"currentEloLabel": "Your current Elo",
"currentEloPlaceholder": "Elo",
"kFactorLabel": "Your K-Factor",
"kFactorPlaceholder": "K-Factor",
"resultsTitle": "Results",
"opponentEloPlaceholder": "Opponent Elo",
"gameResultPlaceholder": "Result...",
"gameResult": "{result, select, win {Win} draw {Draw} loss {Loss} other {{result}}}",
"addGameButton": "Add another game result"
} }
} }
+14 -2
View File
@@ -19,8 +19,7 @@
"Nav": { "Nav": {
"title": "Échecs France", "title": "Échecs France",
"tournaments": "Tournois", "tournaments": "Tournois",
"about": "Qui Sommes-Nous", "elo": "Calculette Elo"
"contact": "Contactez-Nous"
}, },
"Footer": { "Footer": {
@@ -122,5 +121,18 @@
"clearForm": "Réinitialiser", "clearForm": "Réinitialiser",
"success": "Merci pour votre message.", "success": "Merci pour votre message.",
"failure": "Oops, quelque chose ne va pas. Veuillez réessayer SVP." "failure": "Oops, quelque chose ne va pas. Veuillez réessayer SVP."
},
"Elo": {
"title": "Calculette Elo",
"info": "Utilisez ce calculateur pour vous donner une idée de votre nouveau classement Elo après un tournoi.",
"currentEloLabel": "Votre Elo actuel",
"currentEloPlaceholder": "Elo",
"kFactorLabel": "Coefficient",
"resultsTitle": "Résultats",
"opponentEloPlaceholder": "Classement de l'adversaire",
"gameResultPlaceholder": "Résultat...",
"gameResult": "{result, select, win {Victoire} draw {Match nul} loss {Défaite} other {{result}}}",
"addGameButton": "Ajouter un autre résultat de partie"
} }
} }
+1
View File
@@ -7,6 +7,7 @@
"build": "ANALYZE=true next build", "build": "ANALYZE=true next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typeCheck": "tsc -p ./tsconfig.json",
"format": "prettier --check \"**/*.{js,jsx,ts,tsx}\"", "format": "prettier --check \"**/*.{js,jsx,ts,tsx}\"",
"format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\"" "format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\""
}, },
+2
View File
@@ -27,6 +27,8 @@ module.exports = {
900: "#00151F", 900: "#00151F",
950: "#000203", 950: "#000203",
}, },
success: "#00ac39",
error: "#ea5f17",
}, },
minHeight: { minHeight: {
// We use 100svh, falling back to the the --1svh var that we add as a polyfill for older browsers. // We use 100svh, falling back to the the --1svh var that we add as a polyfill for older browsers.