mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Elo calculator
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
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";
|
||||
|
||||
@@ -25,6 +25,13 @@ export default function Footer() {
|
||||
>
|
||||
<FaGithub />
|
||||
</Link>
|
||||
<Link
|
||||
href="/qui-sommes-nous"
|
||||
aria-label={t("contactAria")}
|
||||
className="mr-4"
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contactez-nous"
|
||||
aria-label={t("contactAria")}
|
||||
|
||||
@@ -72,18 +72,10 @@ const HamburgerMenu = ({
|
||||
</li>
|
||||
<li className="py-5 text-center text-xl">
|
||||
<Link
|
||||
href="/qui-sommes-nous"
|
||||
href="/elo"
|
||||
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
|
||||
>
|
||||
{t("about")}
|
||||
</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")}
|
||||
{t("elo")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -8,8 +8,7 @@ export default function Navbar() {
|
||||
|
||||
const links = [
|
||||
{ title: t("tournaments"), route: "/tournois" },
|
||||
{ title: t("about"), route: "/qui-sommes-nous" },
|
||||
{ title: t("contact"), route: "/contactez-nous" },
|
||||
{ title: t("elo"), route: "/elo" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ const ThemeSwitcher = () => {
|
||||
className="inline-block h-4 w-4"
|
||||
>
|
||||
<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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
|
||||
@@ -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
@@ -19,8 +19,7 @@
|
||||
"Nav": {
|
||||
"title": "Échecs France",
|
||||
"tournaments": "Tournaments",
|
||||
"about": "About Us",
|
||||
"contact": "Contact"
|
||||
"elo": "Elo Calculator"
|
||||
},
|
||||
|
||||
"Footer": {
|
||||
@@ -119,5 +118,19 @@
|
||||
"clearForm": "Clear Form",
|
||||
"success": "Thank you for your message.",
|
||||
"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
@@ -19,8 +19,7 @@
|
||||
"Nav": {
|
||||
"title": "Échecs France",
|
||||
"tournaments": "Tournois",
|
||||
"about": "Qui Sommes-Nous",
|
||||
"contact": "Contactez-Nous"
|
||||
"elo": "Calculette Elo"
|
||||
},
|
||||
|
||||
"Footer": {
|
||||
@@ -122,5 +121,18 @@
|
||||
"clearForm": "Réinitialiser",
|
||||
"success": "Merci pour votre message.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "ANALYZE=true next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typeCheck": "tsc -p ./tsconfig.json",
|
||||
"format": "prettier --check \"**/*.{js,jsx,ts,tsx}\"",
|
||||
"format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\""
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ module.exports = {
|
||||
900: "#00151F",
|
||||
950: "#000203",
|
||||
},
|
||||
success: "#00ac39",
|
||||
error: "#ea5f17",
|
||||
},
|
||||
minHeight: {
|
||||
// We use 100svh, falling back to the the --1svh var that we add as a polyfill for older browsers.
|
||||
|
||||
Reference in New Issue
Block a user