diff --git a/app/[locale]/components/Footer.tsx b/app/[locale]/components/Footer.tsx index a0d385a..9333aa0 100644 --- a/app/[locale]/components/Footer.tsx +++ b/app/[locale]/components/Footer.tsx @@ -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() { > + + +
  • - {t("about")} - -
  • -
  • - - {t("contact")} + {t("elo")}
  • diff --git a/app/[locale]/components/Navbar.tsx b/app/[locale]/components/Navbar.tsx index 3fd7c6f..079c5e1 100644 --- a/app/[locale]/components/Navbar.tsx +++ b/app/[locale]/components/Navbar.tsx @@ -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 ( diff --git a/app/[locale]/components/ThemeSwitcher.tsx b/app/[locale]/components/ThemeSwitcher.tsx index 497ca19..5984ff6 100644 --- a/app/[locale]/components/ThemeSwitcher.tsx +++ b/app/[locale]/components/ThemeSwitcher.tsx @@ -23,7 +23,7 @@ const ThemeSwitcher = () => { className="inline-block h-4 w-4" > diff --git a/app/[locale]/elo/page.tsx b/app/[locale]/elo/page.tsx new file mode 100644 index 0000000..627e8f7 --- /dev/null +++ b/app/[locale]/elo/page.tsx @@ -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 extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +type EloFormValues = DeepPartial>; + +export default function Elo() { + const t = useTranslations("Elo"); + + const form = useForm({ + 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( + (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 ( +
    +
    +

    + {t("title")} +

    +

    + {t("info")} +

    + + +
    +
    + + + ({ + value: k, + label: k, + }))} + required + /> +
    + +

    + {t("resultsTitle")} +

    + +
    + {gameFields.map((game, i) => { + return ( +
    +
    +
    + + + ({ + value: result, + label: t("gameResult", { result }), + }))} + required + /> +
    + + {gameFields.length > 1 && ( + + )} +
    + + {deltas[i]?.delta !== undefined && ( +
    1 && "mr-10", + i === deltas.length - 1 && "font-bold", + )} + > + {Math.round(deltas[i]!.rating)} ( + 0 && "text-success", + deltas[i].delta! < 0 && "text-error", + )} + > + {deltas[i]!.delta! >= 0 ? "+" : ""} {deltas[i].delta!} + + ) +
    + )} +
    + ); + })} +
    + +
    + +
    +
    +
    + +
    +
    +
    + ); +} diff --git a/messages/en.json b/messages/en.json index 5ef0a94..d8fbd59 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/messages/fr.json b/messages/fr.json index b251cc6..17d7ee1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" } } diff --git a/package.json b/package.json index e3c43b6..cf710e2 100644 --- a/package.json +++ b/package.json @@ -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}\"" }, diff --git a/tailwind.config.js b/tailwind.config.js index f6f17e2..efcdeda 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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.