Tournament select field

This commit is contained in:
Timothy Armes
2023-10-16 18:07:38 +02:00
committed by Owen Rees
parent a0f9d4dd4e
commit db369a3979
15 changed files with 518 additions and 88 deletions
+56 -44
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isEmpty, sortBy } from "lodash";
@@ -11,7 +11,7 @@ import { z } from "zod";
import { Spinner } from "@/components/Spinner";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
import { TournamentSelectField } from "@/components/form/TournamentSelectField";
import { fetchTournamentResultsSchema } from "@/schemas";
import { Link } from "@/utils/navigation";
import { trpc } from "@/utils/trpc";
@@ -23,6 +23,7 @@ import { TournamentResults } from "./TournamentResults";
const kFactors = ["40", "20", "10"];
const formSchema = fetchTournamentResultsSchema.extend({
tournamentId: z.string().optional(),
player: z.string().optional(),
kFactor: z.string(),
});
@@ -41,14 +42,14 @@ export default function Elo() {
[searchParams],
);
const url = searchParams.get("url") ?? "";
const tournamentId = searchParams.get("tId") ?? "";
const kFactorParam = searchParams.get("k");
const player = searchParams.get("player") ?? "";
const kFactor =
kFactorParam && kFactors.includes(kFactorParam) ? kFactorParam : "20";
const hasUrl = !isEmpty(url.trim());
const hasTournamentId = !isEmpty(tournamentId.trim());
const {
data: allResults,
@@ -56,13 +57,13 @@ export default function Elo() {
error,
} = trpc.fetchTournamentResults.useQuery(
{
url: url?.trim(),
id: tournamentId?.trim(),
},
{
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: 10 * 60 * 1000,
enabled: hasUrl,
enabled: hasTournamentId,
retry: false,
},
);
@@ -78,29 +79,18 @@ export default function Elo() {
const form = useForm<FetchResultsFormValues>({
resolver: zodResolver(fetchTournamentResultsSchema),
defaultValues: {
url: url ?? "",
tournamentId: tournamentId ?? "",
kFactor: kFactor ?? "20",
player: player ?? "",
},
});
const onSubmit = async (input: FetchResultsFormValues) => {
current.set("url", input.url);
if (input.kFactor) current.set("k", input.kFactor);
const search = current.toString();
const query = search ? `?${search}` : "";
// Push the new URL
router.push(`${pathname}${query}`);
};
const clearForm = () => {
current.delete("url");
const clearForm = useCallback(() => {
current.delete("tId");
current.delete("k");
current.delete("player");
form.setValue("url", "");
form.setValue("tournamentId", "");
form.setValue("kFactor", "20");
form.setValue("player", "");
@@ -108,14 +98,31 @@ export default function Elo() {
const query = search ? `?${search}` : "";
router.push(`${pathname}${query}`);
};
}, [current, form, pathname, router]);
useEffect(() => {
// We subscribe to form changes and update the URL parameters
const subscription = form.watch((value, { name, type }) => {
let update = false;
if (type === "change") {
if (name === "player" && value.player !== player && value.player) {
if (
name === "tournamentId" &&
value.tournamentId !== tournamentId &&
value.tournamentId
) {
current.set("tId", value.tournamentId);
update = true;
} else if (
name === "tournamentId" &&
value.tournamentId !== tournamentId &&
value.tournamentId === null
) {
clearForm();
} else if (
name === "player" &&
value.player !== player &&
value.player
) {
current.set("player", value.player);
update = true;
} else if (
@@ -135,12 +142,22 @@ export default function Elo() {
}
});
return () => subscription.unsubscribe();
}, [current, form, form.watch, kFactor, pathname, player, router]);
}, [
clearForm,
current,
form,
form.watch,
kFactor,
pathname,
player,
router,
tournamentId,
]);
useEffect(() => {
// When the URL changes, we update the form values
if (url !== form.getValues("url")) {
form.setValue("url", url);
if (tournamentId !== form.getValues("tournamentId")) {
form.setValue("tournamentId", tournamentId);
}
if (player !== form.getValues("player")) {
@@ -150,7 +167,7 @@ export default function Elo() {
if (kFactor !== form.getValues("kFactor")) {
form.setValue("kFactor", form.getValues("kFactor"));
}
}, [searchParams, form, url, player, kFactor]);
}, [searchParams, form, player, kFactor, tournamentId]);
if (error) {
console.error(error);
@@ -171,7 +188,7 @@ export default function Elo() {
{t("info")}
</p>
{hasUrl && !isFetching && !error && (
{hasTournamentId && !isFetching && !error && (
<div className="mx-auto mb-8 flex justify-center">
<button
type="button"
@@ -184,22 +201,13 @@ export default function Elo() {
)}
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-end gap-4">
<TextField
name="url"
<form className="space-y-8">
<TournamentSelectField
name="tournamentId"
control={form.control}
label={t("resultsUrlLabel")}
placeholder={t("resultsUrlLabel")}
placeholder={t("searchTournamentPlaceholder")}
noOptionsMessage={() => t("noTournamentsFound")}
/>
<button
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg border border-transparent 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"
>
{t("fetchResultsButton")}
</button>
</div>
{isFetching && (
<div className="mt-8 flex justify-center">
@@ -253,7 +261,11 @@ export default function Elo() {
</form>
</FormProvider>
{hasUrl && !isFetching && player && playerResults && !error && (
{hasTournamentId &&
!isFetching &&
player &&
playerResults &&
!error && (
<TournamentResults
playerId={player}
kFactor={parseInt(kFactor)}
@@ -261,7 +273,7 @@ export default function Elo() {
/>
)}
{((!hasUrl && !isFetching) || !!error) && (
{((!hasTournamentId && !isFetching) || !!error) && (
<>
<div className="relative my-8">
<div className="absolute top-1/2 z-10 h-[1px] w-full bg-gray-500 dark:bg-gray-400" />
+3 -8
View File
@@ -3,19 +3,13 @@ import { groupBy } from "lodash";
import clientPromise from "@/lib/mongodb";
import { TournamentData } from "@/types";
import { TimeControl, Tournament } from "@/types";
import { TimeControl, Tournament, tcMap } from "@/types";
import { errorLog } from "@/utils/logger";
import TournamentsDisplay from "./TournamentsDisplay";
export const revalidate = 3600; // Revalidate cache every 6 hours
const tcMap: Record<TournamentData["time_control"], TimeControl> = {
"Cadence Lente": TimeControl.Classic,
Rapide: TimeControl.Rapid,
Blitz: TimeControl.Blitz,
};
const getTournaments = async () => {
try {
const client = await clientPromise;
@@ -35,7 +29,7 @@ const getTournaments = async () => {
},
{
$match: {
status: { $ne: "completed" }
status: { $ne: "completed" },
},
},
{
@@ -94,6 +88,7 @@ const getTournaments = async () => {
return {
id: t._id.toString(),
ffeId: t.tournament_id,
groupId,
tournament: t.tournament,
town: t.town,
+187
View File
@@ -0,0 +1,187 @@
import React, { useEffect, useRef, useState } from "react";
import { get, isArray, isNil } from "lodash";
import { useTranslations } from "next-intl";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
useWatch,
} from "react-hook-form";
import { ActionMeta, GroupBase, OnChangeValue, Props } from "react-select";
import AsyncSelect from "react-select/async";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
import { BaseOption, classNames } from "./SelectField";
export type AsyncSelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
"options" | "onChange" | "value" | "classNames"
> & {
required?: boolean;
loadOption: (id: T) => Promise<BaseOption<T, D> | undefined>;
loadOptions: (searchString?: string) => Promise<BaseOption<T, D>[]>;
separators?: boolean;
}
>;
export const AsyncSelectField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
>({
name,
control,
className,
label,
hideErrorMessage,
required,
loadOption,
loadOptions,
placeholder,
separators,
...selectProps
}: AsyncSelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
const t = useTranslations("App");
const [loadingValues, setLoadingValues] = useState(false);
const [loadedInitialValues, setLoadedInitialValues] = useState(false);
const [loadingOptions, setLoadingOptions] = useState(false);
const valueOptions = useRef<BaseOption<T, D>[]>([]);
const searchOptions = useRef<BaseOption<T, D>[]>([]);
const form = useFormContext();
const value = useWatch({ control: form.control, name });
// Fetch the option(s) for the current value(s) when mounted
useEffect(() => {
const doLoad = async () => {
try {
const values = !value ? [] : isArray(value) ? value : [value];
const unFetchedValues = values.filter(
(v) =>
valueOptions.current.find((o) => o.value === value) === undefined,
);
if (unFetchedValues.length > 0) {
setLoadingValues(true);
const options = await Promise.all(
unFetchedValues.map((v: T) => {
return loadOption(v);
}),
);
valueOptions.current = [
...valueOptions.current,
...options.filter((vo): vo is BaseOption<T, D> => vo !== undefined),
];
setLoadingValues(false);
// This forced a state update, which enures that the select is updated once the query has finished
setLoadedInitialValues(true);
}
} catch (e) {
console.log(e);
setLoadingValues(false);
}
};
doLoad();
}, [value, setLoadingValues, loadOption]);
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const valueToOption = (value: T) =>
[...valueOptions.current, ...searchOptions.current].find(
(o) => o.value === value,
) ?? { value, label: "" };
const fetchOptions = async (searchString?: string) => {
setLoadingOptions(true);
const options = await loadOptions(searchString);
searchOptions.current = options;
setLoadingOptions(false);
return options;
};
return (
<Field {...{ name, control, className, label, hideErrorMessage, required }}>
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => {
const onSelectChange = (
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
actionMeta: ActionMeta<BaseOption<T, D>>,
) => {
console.log(newValue, actionMeta);
if (isNil(newValue) || actionMeta.action === "clear") {
onChange(null);
valueOptions.current = [];
} else if (isArray(newValue)) {
onChange(newValue.map((option) => option.value));
valueOptions.current = newValue;
} else {
onChange((newValue as BaseOption<T, D>).value);
valueOptions.current = [newValue as BaseOption<T, D>];
}
};
const optionValue =
isNil(value) || value === ""
? null
: isArray(value)
? // @ts-ignore - this is too complex for TS to understand
value.map(valueToOption)
: valueToOption(value);
return (
<AsyncSelect<BaseOption<T, D>, IsMulti>
isClearable
value={optionValue}
onChange={onSelectChange}
loadOptions={fetchOptions}
defaultOptions
isLoading={loadingOptions || loadingValues}
noOptionsMessage={() => t("noOptionsMessage")}
placeholder={placeholder ?? t("selectPlaceholder")}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none",
},
}),
}}
classNames={classNames<BaseOption<T, D>, IsMulti>(
hasError,
separators ?? false,
)}
{...selectProps}
/>
);
}}
/>
</Field>
);
};
+10 -4
View File
@@ -29,12 +29,12 @@ export type BaseOption<T = string, D = unknown> = {
export const classNames = <Option, IsMulti extends boolean = false>(
hasError: boolean,
separators: boolean,
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
container: () => "w-full",
valueContainer: () => "text-gray-900 dark:text-white",
indicatorsContainer: () => "flex items-center self-stretch shrink-0",
clearIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
clearIndicator: () => "flex items-center pr-2 text-gray-900 dark:text-white",
dropdownIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
indicatorSeparator: () => "w-px text-gray-900 dark:text-white",
@@ -55,7 +55,7 @@ export const classNames = <Option, IsMulti extends boolean = false>(
"block truncate pr-2 placeholder-gray dark:placeholder-gray-400",
menu: () =>
twMerge(
"!z-10 mt-2 rounded-lg border border-gray-200 bg-white p-1",
"!z-30 mt-2 rounded-lg border border-gray-200 bg-white p-1",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
),
@@ -63,6 +63,7 @@ export const classNames = <Option, IsMulti extends boolean = false>(
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
separators && "border-b border-gray-200",
isDisabled && "opacity-50",
isFocused && "hover:bg-primary-500 hover:text-white",
),
@@ -83,6 +84,7 @@ export type SelectFieldProps<
"onChange" | "value" | "classNames" | "name"
> & {
required?: boolean;
separators?: boolean;
}
>;
@@ -103,6 +105,7 @@ export const SelectField = <
required,
placeholder,
separators,
options,
...selectProps
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
@@ -176,7 +179,10 @@ export const SelectField = <
},
}),
}}
classNames={classNames<BaseOption<T, D>, IsMulti>(hasError)}
classNames={classNames<BaseOption<T, D>, IsMulti>(
hasError,
separators ?? false,
)}
{...selectProps}
/>
);
@@ -0,0 +1,87 @@
import { useTranslations } from "next-intl";
import { FieldPath, FieldValues } from "react-hook-form";
import { TimeControlColours } from "@/constants";
import { SearchedTournament, trpc } from "@/utils/trpc";
import { AsyncSelectField, AsyncSelectFieldProps } from "./AsyncSelectField";
type TournamentSelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<
AsyncSelectFieldProps<
TFieldValues,
TFieldName,
false,
string,
SearchedTournament
>,
"loadOptions" | "loadOption" | "separators"
>;
export const TournamentSelectField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...rest
}: TournamentSelectFieldProps<TFieldValues, TFieldName>) => {
const at = useTranslations("App");
const client = trpc.useContext();
const loadOption = async (ffeId: string) => {
const tournament = await client.getTournamentDetails.fetch({ ffeId });
return !tournament
? undefined
: {
value: tournament.ffeId,
label: tournament.tournament,
data: tournament,
};
};
const loadOptions = async (searchValue?: string) => {
const tournaments = await client.searchTournaments.fetch({
searchValue: searchValue ?? "",
});
return (tournaments ?? []).map((tournament) => ({
value: tournament.ffeId,
label: tournament.tournament,
data: tournament,
}));
};
return (
<AsyncSelectField
{...rest}
isSearchable
separators
loadOption={loadOption}
loadOptions={loadOptions}
isClearable={true}
formatOptionLabel={(option, context) => {
if (context.context === "value") return option.label;
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-xs uppercase">
<div className="flex-1">{option.data?.town}</div>
<div
className="ml-2 rounded-sm px-1 py-0.5 align-middle text-[9px]/[11px] uppercase text-white"
style={{
background: `${TimeControlColours[option.data!.timeControl]}`,
}}
>
{at("timeControlEnum", { tc: option.data?.timeControl })}
</div>
<div className="text-xs">{option.data?.date}</div>
</div>
<div>{option.data?.tournament}</div>
</div>
);
}}
/>
);
};
+2 -3
View File
@@ -152,9 +152,8 @@
"gameResult": "{result, select, win {Win} draw {Draw} loss {Loss} other {{result}}}",
"addGameButton": "Add another game result",
"resultsUrlLabel": "Paste a link to the FFE results page",
"resultsUrlPlaceholder": "http://echecs.asso.fr/FicheTournoi.aspx?Ref=...",
"fetchResultsButton": "Fetch",
"searchTournamentPlaceholder": "Find a tournament...",
"noTournamentsFound": "No tournaments found",
"enterManualResultsButton": "Enter results manually",
"choosePlayerLabel": "Choose a player",
"choosePlayerPlaceholder": "Player",
+2 -3
View File
@@ -154,9 +154,8 @@
"gameResult": "{result, select, win {Victoire} draw {Nul} loss {Défaite} other {{result}}}",
"addGameButton": "Ajouter un autre résultat de partie",
"resultsUrlLabel": "Collez un lien vers la page de résultats de la FFE",
"resultsUrlPlaceholder": "http://echecs.asso.fr/FicheTournoi.aspx?Ref=...",
"fetchResultsButton": "Récupérer",
"searchTournamentPlaceholder": "Rechecher un tournois...",
"noTournamentsFound": "Aucun tournoi trouvé",
"enterManualResultsButton": "Saisir les résultats manuellement",
"choosePlayerLabel": "Choisissez un joueur",
"choosePlayerPlaceholder": "Joueur",
+1 -1
View File
@@ -28,5 +28,5 @@ export const addTournamentSchema = z.object({
});
export const fetchTournamentResultsSchema = z.object({
url: z.string().url({ message: "FormValidation.url" }),
id: z.string().min(1, { message: "FormValidation.required" }),
});
+4
View File
@@ -1,11 +1,15 @@
import { addTournament } from "./procedures/addTournament";
import { contactUs } from "./procedures/contactUs";
import { fetchTournamentResults } from "./procedures/fetchTournamentResults";
import { getTournamentDetails } from "./procedures/getTournamentDetails";
import { searchTournaments } from "./procedures/searchTournaments";
import { router } from "./trpc";
export const appRouter = router({
contactUs,
addTournament,
searchTournaments,
getTournamentDetails,
fetchTournamentResults,
});
+1 -1
View File
@@ -24,7 +24,7 @@ export const addTournament = publicProcedure
const { name, email, message, tournament } = input;
const tournamentData: Omit<TournamentData, "_id"> = {
const tournamentData: Omit<TournamentData, "_id" | "tournament_id"> = {
...tournament,
date: format(tournament.date, "dd/MM/yyyy"),
time_control: tcMap[tournament.time_control],
@@ -5,7 +5,7 @@ import { errorLog } from "@/utils/logger";
import { publicProcedure } from "../trpc";
const scrapedSchema = z.array(
const dbSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
@@ -55,7 +55,7 @@ const getEloDetails = (elo: string | null) => {
};
const getResultDetails = (
results: z.infer<typeof scrapedSchema>[number]["results"][number],
results: z.infer<typeof dbSchema>[number]["results"][number],
) => {
if (results.opponent_name === null) {
return { result: 0, lostByForfeit: true, wonByForfeit: false };
@@ -107,17 +107,6 @@ export const fetchTournamentResults = publicProcedure
.output(outputSchema)
.query(async ({ input }) => {
try {
// Depending on which page the user copied the URL from, we need to extract the tournament ID
// http://echecs.asso.fr/FicheTournoi.aspx?Ref=59975
// http://echecs.asso.fr/Resultats.aspx?URL=Tournois/Id/59975/59975&Action=Ls
const tournamentId =
input.url.match(/Ref=(\d+)/)?.[1] ?? input.url.match(/Id\/(\d+)/)?.[1];
if (!tournamentId) {
throw new Error("ERR_NO_TOURNAMENT_ID");
}
const headers = new Headers();
const apiKey = process.env.RESULTS_API_KEY;
@@ -125,8 +114,10 @@ export const fetchTournamentResults = publicProcedure
headers.append("api-key", apiKey);
}
console.log(input.id);
const rawResults = await fetch(
`${process.env.RESULTS_SCRAPER_URL}${tournamentId}`,
`${process.env.RESULTS_SCRAPER_URL}${input.id}`,
{
headers: headers,
},
@@ -138,7 +129,7 @@ export const fetchTournamentResults = publicProcedure
throw new Error("ERR_TOURNAMENT_NOT_FOUND");
}
const parsedResults = scrapedSchema.parse(results);
const parsedResults = dbSchema.parse(results);
return parsedResults.map<z.infer<typeof outputSchema>[number]>(
(player) => ({
id: player.id,
@@ -158,7 +149,7 @@ export const fetchTournamentResults = publicProcedure
}),
);
} catch (error) {
reportFetchError(input.url, error);
reportFetchError(input.id, error);
errorLog(JSON.stringify(error, null, 2));
throw error;
}
@@ -0,0 +1,45 @@
import { z } from "zod";
import clientPromise from "@/lib/mongodb";
import { TournamentData } from "@/types";
import { TimeControl, tcMap } from "@/types";
import { errorLog } from "@/utils/logger";
import { publicProcedure } from "../trpc";
const inputSchema = z.object({
ffeId: z.string(),
});
export const getTournamentDetails = publicProcedure
.input(inputSchema)
.query(async ({ input }) => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
const t = await db
.collection("tournaments")
.findOne<TournamentData>({ tournament_id: input.ffeId });
if (t === null) {
return null;
}
const timeControl = tcMap[t.time_control] ?? TimeControl.Other;
return {
id: t._id.toString(),
ffeId: t.tournament_id,
tournament: t.tournament,
town: t.town,
department: t.department,
date: t.date,
url: t.url,
timeControl,
status: t.status,
};
} catch (error) {
errorLog(JSON.stringify(error, null, 2));
throw error;
}
});
@@ -0,0 +1,96 @@
import { endOfDay } from "date-fns";
import { z } from "zod";
import clientPromise from "@/lib/mongodb";
import { TournamentData } from "@/types";
import { TimeControl, Tournament, tcMap } from "@/types";
import { errorLog } from "@/utils/logger";
import { removeDiacritics } from "@/utils/string";
import { publicProcedure } from "../trpc";
const inputSchema = z.object({
searchValue: z.string(),
limit: z.number().optional(),
});
type SearchedTournament = Pick<
Tournament,
| "id"
| "ffeId"
| "date"
| "department"
| "status"
| "timeControl"
| "tournament"
| "town"
| "url"
>;
export const searchTournaments = publicProcedure
.input(inputSchema)
.query(async ({ input }) => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
const searchTerms = input.searchValue
.split(" ")
.map((s) => removeDiacritics(s.trim()))
.filter((s) => s !== "")
.map((s) => ({ tournament_index: { $regex: s, $options: "i" } }));
const data = await db
.collection("tournaments")
.aggregate<TournamentData>([
{
$addFields: {
dateParts: {
$dateFromString: {
dateString: "$end_date",
format: "%d/%m/%Y",
},
},
},
},
{
$match: {
$and: [
{ federation: { $eq: "FFE" } },
{ dateParts: { $lte: endOfDay(new Date()) } },
...searchTerms,
],
},
},
{
$sort: {
dateParts: -1,
},
},
{ $limit: input.limit ?? 20 },
{
$unset: "dateParts",
},
])
.toArray();
return data.map<SearchedTournament>((t) => {
const timeControl = tcMap[t.time_control] ?? TimeControl.Other;
return {
id: t._id.toString(),
ffeId: t.tournament_id,
tournament: t.tournament,
town: t.town,
department: t.department,
date: t.date,
url: t.url,
timeControl,
status: t.status,
};
});
} catch (error) {
errorLog(JSON.stringify(error, null, 2));
throw error;
}
});
+8
View File
@@ -5,6 +5,7 @@ export type Status = "scheduled" | "ongoing" | "finished";
export type TournamentData = {
_id: ObjectId;
tournament_id: string;
town: string;
department: string;
country: string;
@@ -38,6 +39,7 @@ export enum TimeControl {
export type Tournament = {
id: string;
ffeId: string;
groupId: string;
town: string;
department: string;
@@ -51,6 +53,12 @@ export type Tournament = {
status: Status;
};
export const tcMap: Record<TournamentData["time_control"], TimeControl> = {
"Cadence Lente": TimeControl.Classic,
Rapide: TimeControl.Rapid,
Blitz: TimeControl.Blitz,
};
export type Club = {
id: string;
name: string;
+1
View File
@@ -7,5 +7,6 @@ export type APIRouterInput = inferRouterInputs<AppRouter>;
export type APIRouterOutput = inferRouterOutputs<AppRouter>;
export type TournamentResultsData = APIRouterOutput["fetchTournamentResults"];
export type SearchedTournament = APIRouterOutput["searchTournaments"][number];
export const trpc = createTRPCReact<AppRouter>();