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 { 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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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": {
|
"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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}\""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user