diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e4ab4ea
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+MONGODB_URI=
+DISCORD_WEBHOOK_URL=
+RESULTS_SCRAPER_URL=
+GMAIL_USER=
+GMAIL_PASS=
diff --git a/app/[locale]/elo/FetchResultsForm.tsx b/app/[locale]/elo/FetchResultsForm.tsx
deleted file mode 100644
index 8a552cc..0000000
--- a/app/[locale]/elo/FetchResultsForm.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useTranslations } from "next-intl";
-import { FormProvider, useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { TextField } from "@/components/form/TextField";
-import { fetchTournamentResultsSchema } from "@/schemas";
-import { trpc } from "@/utils/trpc";
-
-type FetchResultsFormValues = z.infer;
-
-export const FetchResultsForm = () => {
- const t = useTranslations("Elo");
- const fetchResults = trpc.fetchTournamentResults.useMutation();
-
- const form = useForm({
- resolver: zodResolver(fetchTournamentResultsSchema),
- });
-
- const onSubmit = async (input: FetchResultsFormValues) => {
- try {
- const result = await fetchResults.mutateAsync(input);
- console.log(result);
- } catch (err: unknown) {
- console.log(err);
- }
- };
-
- return (
-
-
-
- );
-};
diff --git a/app/[locale]/elo/ManualEloForm.tsx b/app/[locale]/elo/ManualEloForm.tsx
new file mode 100644
index 0000000..5bcc320
--- /dev/null
+++ b/app/[locale]/elo/ManualEloForm.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+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";
+import { getNewRating } from "@/utils/eloCalculator";
+
+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 const ManualEloForm = () => {
+ 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 } = getNewRating(
+ currentElo!,
+ game.opponentElo!,
+ result,
+ parseInt(kFactor!, 10),
+ );
+ return {
+ rating: acc.rating + delta,
+ deltas: [...acc.deltas, { rating: acc.rating + delta, delta }],
+ };
+ }
+
+ return {
+ rating: acc.rating,
+ deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }],
+ };
+ },
+ { rating: currentElo!, deltas: [] },
+ )
+ : { rating: currentElo!, deltas: [] };
+
+ const deltas = calculations.deltas;
+
+ return (
+
+
+
+ );
+};
diff --git a/app/[locale]/elo/TournamentResults.tsx b/app/[locale]/elo/TournamentResults.tsx
new file mode 100644
index 0000000..0db56be
--- /dev/null
+++ b/app/[locale]/elo/TournamentResults.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import { last } from "lodash";
+import { useTranslations } from "next-intl";
+import { twMerge } from "tailwind-merge";
+
+import { getNewRating } from "@/utils/eloCalculator";
+import { TournamentResultsData } from "@/utils/trpc";
+
+type TournamentResultsProps = {
+ results: TournamentResultsData;
+ playerId: string;
+ kFactor: number;
+};
+
+export const TournamentResults = ({
+ results,
+ playerId,
+ kFactor,
+}: TournamentResultsProps) => {
+ const t = useTranslations("Elo");
+
+ const playerResults = results?.find((p) => p.id === playerId);
+ if (!playerResults) {
+ return null;
+ }
+
+ type Deltas = {
+ rating: number;
+ deltas: {
+ rating: number;
+ delta: number | undefined;
+ opponentName: string | null;
+ opponentElo: number | null;
+ result: number | null;
+ lostByForfeit: boolean;
+ wonByForfeit: boolean;
+ estimated: boolean;
+ national: boolean;
+ colour: string;
+ }[];
+ };
+
+ const currentElo = playerResults.elo;
+
+ const calculations = !Number.isNaN(currentElo)
+ ? (playerResults.results ?? []).reduce(
+ (acc, game) => {
+ const info = {
+ opponentName: game.opponent,
+ opponentElo: game.elo,
+ lostByForfeit: game.lostByForfeit,
+ wonByForfeit: game.wonByForfeit,
+ estimated: game.estimated,
+ national: game.national,
+ colour: game.colour,
+ result: game.result,
+ };
+
+ if (
+ !game.wonByForfeit &&
+ !game.lostByForfeit &&
+ !game.estimated &&
+ !game.national &&
+ game.result !== null
+ ) {
+ const { delta } = getNewRating(
+ currentElo!,
+ game.elo!,
+ game.result as 1 | 0 | 0.5,
+ kFactor,
+ );
+ return {
+ rating: acc.rating + delta,
+ deltas: [
+ ...acc.deltas,
+ { rating: acc.rating + delta, delta, ...info },
+ ],
+ };
+ }
+
+ return {
+ rating: acc.rating,
+ deltas: [
+ ...acc.deltas,
+ { rating: acc.rating, delta: undefined, ...info },
+ ],
+ };
+ },
+ { rating: currentElo!, deltas: [] },
+ )
+ : { rating: currentElo!, deltas: [] };
+
+ const deltas = calculations.deltas;
+ const totalDelta = Math.round(
+ deltas.reduce((acc, delta) => acc + (delta.delta ?? 0), 0),
+ );
+
+ const noChangeCount = (deltas ?? []).filter(
+ ({ wonByForfeit, lostByForfeit, estimated, national }) =>
+ wonByForfeit || lostByForfeit || estimated || national,
+ ).length;
+
+ if (playerResults.estimated || playerResults.national)
+ return {t("needRating")}
;
+
+ return (
+ <>
+
+ {t("initialRating", { rating: Math.round(playerResults.elo) })}
+
+
+
+ {deltas.map((delta, i) => {
+ const { estimated, national, lostByForfeit, wonByForfeit } = delta;
+ const opponentElo = delta.opponentElo
+ ? ` (${delta.opponentElo} ${
+ estimated ? "E" : national ? "N" : "F"
+ })`
+ : "";
+
+ const opponentName = delta.opponentName ? (
+
+
+ {delta.opponentName}
+
+ {opponentElo}
+
+ ) : (
+ "-"
+ );
+
+ const playedWhite = delta.colour === "white";
+
+ const whitePlayer = playedWhite ? playerResults.name : opponentName;
+ const blackPlayer = !playedWhite ? playerResults.name : opponentName;
+ const noChange =
+ wonByForfeit || lostByForfeit || estimated || national;
+
+ const forfeit = delta.opponentName === null;
+
+ let result = "";
+ if (wonByForfeit) {
+ result = t("wonByForfeit");
+ } else if (lostByForfeit) {
+ result = t("lostByForfeit");
+ } else if (delta.result === 0.5) {
+ result = t("draw");
+ } else if (playedWhite && delta.result === 1) {
+ result = t("whiteWin");
+ } else result = t("blackWin");
+
+ return (
+
+ {forfeit ? (
+ | {t("forfeit")} |
+ ) : (
+
+ {whitePlayer}
+ {t("vs")}
+ {blackPlayer}
+ |
+ )}
+
+ {!forfeit && result}
+ |
+
+ {noChange ? (
+ "-"
+ ) : (
+ 0 && "text-success",
+ deltas[i].delta! < 0 && "text-error",
+ )}
+ >
+ {deltas[i]!.delta! >= 0 ? "+" : ""}
+ {deltas[i].delta!}
+
+ )}
+ |
+
+ );
+ })}
+
+
+ {noChangeCount > 0 && (
+
+ {t("noChangeInfo", { noChangeCount })}
+
+ )}
+
+ {deltas.length > 0 && (
+
+ {t.rich("finalRating", {
+ rating: Math.round(last(deltas)!.rating),
+ delta: () => (
+ 0 && "text-success",
+ totalDelta! < 0 && "text-error",
+ )}
+ >
+ {totalDelta >= 0 ? "+" : ""}
+ {totalDelta}
+
+ ),
+ })}
+
+ )}
+ >
+ );
+};
diff --git a/app/[locale]/elo/page.tsx b/app/[locale]/elo/page.tsx
index 6d9d9e1..1f62c0b 100644
--- a/app/[locale]/elo/page.tsx
+++ b/app/[locale]/elo/page.tsx
@@ -1,99 +1,85 @@
"use client";
+import { useState } from "react";
+
import { zodResolver } from "@hookform/resolvers/zod";
+import { isEmpty } 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 Link from "next/link";
+import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
+import { Spinner } from "@/components/Spinner";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
-import { getNewRating } from "@/utils/eloCalculator";
+import { fetchTournamentResultsSchema } from "@/schemas";
+import { trpc } from "@/utils/trpc";
-import { FetchResultsForm } from "./FetchResultsForm";
+import { ManualEloForm } from "./ManualEloForm";
+import { TournamentResults } from "./TournamentResults";
-const resultsSchema = z.object({
- currentElo: z.number().int().positive(),
+const formSchema = fetchTournamentResultsSchema.extend({
+ player: z.string().optional(),
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>;
+type FetchResultsFormValues = z.infer;
export default function Elo() {
const t = useTranslations("Elo");
+ type TranslationKey = Parameters[0];
- const form = useForm({
- resolver: zodResolver(resultsSchema),
+ const [formInput, setFormInput] = useState({
+ url: "",
+ kFactor: "20",
+ });
+
+ const hasUrl = !isEmpty(formInput?.url.trim());
+
+ const {
+ data: allResults,
+ isFetching,
+ error,
+ } = trpc.fetchTournamentResults.useQuery(
+ {
+ url: formInput?.url.trim(),
+ },
+ {
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ cacheTime: 10 * 60 * 1000,
+ enabled: hasUrl,
+ retry: false,
+ },
+ );
+
+ const playerOptions = (allResults ?? []).map((player) => ({
+ value: player.id,
+ label: player.name,
+ }));
+
+ const form = useForm({
+ resolver: zodResolver(fetchTournamentResultsSchema),
defaultValues: {
kFactor: "20",
- games: [{}],
},
});
- const {
- fields: gameFields,
- append: appendGame,
- remove: removeGame,
- } = useFieldArray({
- control: form.control,
- name: "games",
- });
+ const [player, kFactor] = form.watch(["player", "kFactor"]);
+ const playerResults = allResults?.find((p) => p.id === player);
- 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 onSubmit = async (input: FetchResultsFormValues) => {
+ setFormInput(input);
};
- 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 clearForm = () => {
+ form.setValue("url", "");
+ setFormInput({ ...formInput, url: "" });
+ };
- const { delta } = getNewRating(
- currentElo!,
- game.opponentElo!,
- result,
- parseInt(kFactor!, 10),
- );
- return {
- rating: acc.rating + delta,
- deltas: [...acc.deltas, { rating: acc.rating + delta, delta }],
- };
- }
-
- return {
- rating: acc.rating,
- deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }],
- };
- },
- { rating: currentElo!, deltas: [] },
- )
- : { rating: currentElo!, deltas: [] };
-
- const deltas = calculations.deltas;
+ if (error) {
+ console.error(error);
+ }
return (
@@ -108,124 +94,101 @@ export default function Elo() {
{t("info")}
-
-
-
+ {hasUrl && !isFetching && !error && (
+
+
+
+ )}
-