From 488673cb60f7cd9f12365b47621d427477477a34 Mon Sep 17 00:00:00 2001 From: Lzeray Date: Wed, 14 Oct 2026 00:20:36 +0300 Subject: [PATCH] fix(tabletennis): fix many report bugs, add tournament parallelism --- src/api/tabletennis/types.ts | 402 +++++++- src/app/route-tree.gen.ts | 22 - .../routes/_with_menu/tabletennis/events.tsx | 32 - .../_with_menu/tabletennis/tournaments.tsx | 17 +- .../tabletennis/ArchivedTournament.tsx | 253 +++++ src/components/tabletennis/BracketGraph.tsx | 38 +- .../tabletennis/CreateTournamentForm.tsx | 131 +++ src/components/tabletennis/LiveTournament.tsx | 405 ++++++++ .../tabletennis/MergeGuestModal.tsx | 150 +++ .../tabletennis/PlayerActionMenu.tsx | 276 +++--- src/components/tabletennis/PlayersField.tsx | 478 ++++++++++ .../tabletennis/QualificationMatches.tsx | 329 ++++--- src/components/tabletennis/ScoreSheet.tsx | 5 +- .../tabletennis/TableTennisMyMatches.tsx | 51 +- .../tabletennis/TabletennisPage.tsx | 11 +- .../tabletennis/TabletennisPlayersTop.tsx | 106 ++- .../tabletennis/TabletennisTabs.tsx | 3 +- .../tabletennis/TabletennisTournaments.tsx | 891 ++++-------------- .../tabletennis/ValidationMatches.tsx | 275 +++++- src/components/tabletennis/bracket-utils.ts | 7 +- .../tabletennis/useTabletennisRefresh.ts | 19 + 21 files changed, 2767 insertions(+), 1134 deletions(-) delete mode 100644 src/app/routes/_with_menu/tabletennis/events.tsx create mode 100644 src/components/tabletennis/ArchivedTournament.tsx create mode 100644 src/components/tabletennis/CreateTournamentForm.tsx create mode 100644 src/components/tabletennis/LiveTournament.tsx create mode 100644 src/components/tabletennis/MergeGuestModal.tsx create mode 100644 src/components/tabletennis/PlayersField.tsx create mode 100644 src/components/tabletennis/useTabletennisRefresh.ts diff --git a/src/api/tabletennis/types.ts b/src/api/tabletennis/types.ts index db9a6ee4..f27dc282 100644 --- a/src/api/tabletennis/types.ts +++ b/src/api/tabletennis/types.ts @@ -167,6 +167,26 @@ export interface paths { patch?: never; trace?: never; }; + "/my-games": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get My Games + * @description Returns every game of the current player, in the same format as /get-games-by-id. + */ + get: operations["table_tennis_get_my_games"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ping": { parameters: { query?: never; @@ -221,7 +241,8 @@ export interface paths { put?: never; /** * Set Status - * @description Sets a player's status by innohassle_id. + * @description Sets a player's status by innohassle_id. Works in both directions regardless of the rating; + * the automatic promotion at ADVANCED_RATING only happens when the rating crosses it. */ post: operations["table_tennis_set_status"]; delete?: never; @@ -241,8 +262,8 @@ export interface paths { put?: never; /** * Set Rating - * @description Admin endpoint to assign a player's starting RTTF rating, as the organizer does for a newcomer: - * a multiple of 25 and at least 100. The change is added to the player's rating history. + * @description Admin endpoint to set a player's rating by hand to any value from 0. The change is added to + * the player's rating history; reaching ADVANCED_RATING from below promotes a beginner. */ post: operations["table_tennis_set_rating"]; delete?: never; @@ -251,6 +272,49 @@ export interface paths { patch?: never; trace?: never; }; + "/guests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Guest + * @description Admin endpoint to create a guest for someone who has no account yet. A guest can be added to any + * tournament and plays like a registered player; /merge-guest later hands everything over to the account. + */ + post: operations["table_tennis_create_guest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/merge-guest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Merge Guest + * @description Admin endpoint to hand a guest over to the registered player it stood for. The player replaces the + * guest in every tournament and game, and the guest is deleted. A player who has not played yet takes + * the guest's rating, status and history; otherwise the rating changes of the guest's games are added. + */ + post: operations["table_tennis_merge_guest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/reg-tour": { parameters: { query?: never; @@ -264,7 +328,7 @@ export interface paths { * Register Tour * @description Creates a new tournament. Players can be specified either by innohassle_id (player_ids) * or by email (emails) - both lists are optional and can be freely combined. - * Requires at least 2 valid, registered (/reg) players to succeed. + * Nothing is created unless every given player is found; the 400 response lists the missing ones. */ post: operations["table_tennis_register_tour"]; delete?: never; @@ -286,7 +350,7 @@ export interface paths { * Add Player * @description Adds multiple players to an ACTIVE tournament, specified either by innohassle_id (player_ids) * or by email (emails) - both lists are optional and can be freely combined. - * Processes valid players and returns a report of skipped/failed ones without crashing. + * Nobody is added unless every given player can be added; the 400 response lists the failed ones. */ post: operations["table_tennis_add_player"]; delete?: never; @@ -465,6 +529,48 @@ export interface paths { patch?: never; trace?: never; }; + "/delete-tour": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete Tournament + * @description Admin endpoint to delete a tournament created by mistake. Only possible while no game in it + * is finished, so ratings are never affected; unfinished games are deleted with it. + */ + post: operations["table_tennis_delete_tournament"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reopen-tour": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reopen Tournament + * @description Admin endpoint to make an archived tournament active again, so its games and standings can be corrected. + * Bonuses are still granted at most once. + */ + post: operations["table_tennis_reopen_tournament"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/reg-game": { parameters: { query?: never; @@ -622,6 +728,11 @@ export interface components { * @default Beginner */ status: string; + /** + * Placeholder + * @default false + */ + placeholder: boolean; }; /** @example 5eb7cf5a86d9755df3a6c593 */ PydanticObjectId: string; @@ -933,6 +1044,35 @@ export interface operations { }; }; }; + table_tennis_get_my_games: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Unable to verify credentials OR Credentials not provided */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; table_tennis_ping: { parameters: { query?: never; @@ -1058,7 +1198,7 @@ export interface operations { }; }; }; - /** @description Starting rating must be a multiple of {RTTF_START_RATING_STEP} and at least {RTTF_MIN_START_RATING} */ + /** @description Rating cannot be negative */ 400: { headers: { [name: string]: unknown; @@ -1090,6 +1230,110 @@ export interface operations { }; }; }; + table_tennis_create_guest: { + parameters: { + query: { + nickname: string; + rating?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Rating cannot be negative OR Nickname must be 2 to 20 characters long */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description You are not a Table Tennis administrator/coach! */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + table_tennis_merge_guest: { + parameters: { + query: { + guest_id: string; + player_id?: string | null; + email?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description f"The guest and the player are in the same tournament: {', '.join((t.name for t in shared))}" */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description You are not a Table Tennis administrator/coach! */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Registered player not found OR Guest or player not found OR Guest not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; table_tennis_register_tour: { parameters: { query: { @@ -1117,7 +1361,7 @@ export interface operations { }; }; }; - /** @description Bad Request */ + /** @description Tournament name must be 1 to 100 characters long OR A tournament needs at least 2 players */ 400: { headers: { [name: string]: unknown; @@ -1293,13 +1537,6 @@ export interface operations { }; content?: never; }; - /** @description Tournament not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Validation Error */ 422: { headers: { @@ -1346,13 +1583,6 @@ export interface operations { }; content?: never; }; - /** @description Tournament not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Validation Error */ 422: { headers: { @@ -1446,7 +1676,7 @@ export interface operations { }; }; }; - /** @description Every group needs at least 2 players: {small_groups} OR Create groups first */ + /** @description Create groups first OR Every group needs at least 2 players: {small_groups} */ 400: { headers: { [name: string]: unknown; @@ -1460,8 +1690,48 @@ export interface operations { }; content?: never; }; - /** @description Tournament not found */ - 404: { + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + table_tennis_unlock_groups: { + parameters: { + query: { + tour_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Cannot unlock groups: validation games have already started */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description You are not a Table Tennis administrator/coach! */ + 403: { headers: { [name: string]: unknown; }; @@ -1478,7 +1748,7 @@ export interface operations { }; }; }; - table_tennis_unlock_groups: { + table_tennis_set_qual_seeding: { parameters: { query: { tour_id: string; @@ -1487,7 +1757,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": string[]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -1500,7 +1774,7 @@ export interface operations { }; }; }; - /** @description Cannot unlock groups: validation games have already started */ + /** @description Lock the groups first OR At least 2 players are needed for qualification OR Qualification games have already started OR Seeding must contain every participant exactly once OR Finish or cancel the group games in progress first */ 400: { headers: { [name: string]: unknown; @@ -1514,6 +1788,46 @@ export interface operations { }; content?: never; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + table_tennis_finish_tournament: { + parameters: { + query: { + tour_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description You are not a Table Tennis administrator/coach! */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Tournament not found */ 404: { headers: { @@ -1532,7 +1846,7 @@ export interface operations { }; }; }; - table_tennis_set_qual_seeding: { + table_tennis_delete_tournament: { parameters: { query: { tour_id: string; @@ -1541,11 +1855,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": string[]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -1558,7 +1868,7 @@ export interface operations { }; }; }; - /** @description Seeding must contain every participant exactly once OR Qualification games have already started OR Lock the groups first OR At least 2 players are needed for qualification */ + /** @description A tournament with finished games cannot be deleted */ 400: { headers: { [name: string]: unknown; @@ -1590,7 +1900,7 @@ export interface operations { }; }; }; - table_tennis_finish_tournament: { + table_tennis_reopen_tournament: { parameters: { query: { tour_id: string; @@ -1612,6 +1922,13 @@ export interface operations { }; }; }; + /** @description Tournament is already active */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description You are not a Table Tennis administrator/coach! */ 403: { headers: { @@ -1664,7 +1981,7 @@ export interface operations { }; }; }; - /** @description Qualification has already started OR Lock the groups before starting validation games OR A player cannot play a match against themselves! OR Validation games are played only inside one group OR One or both players are not in this tournament's player list */ + /** @description A player cannot play a match against themselves! OR Qualification has already started OR One or both players are not in this tournament's player list OR Validation games are played only inside one group OR Lock the groups before starting validation games */ 400: { headers: { [name: string]: unknown; @@ -1678,7 +1995,7 @@ export interface operations { }; content?: never; }; - /** @description Tournament not found OR One or both players are not registered in the system (/reg) */ + /** @description One or both players are not registered in the system (/reg) */ 404: { headers: { [name: string]: unknown; @@ -1728,7 +2045,7 @@ export interface operations { }; }; }; - /** @description This game has already been finished. Recalculating rating is not allowed. */ + /** @description Group games cannot be finished after qualification started OR This game has already been finished. Recalculating rating is not allowed. */ 400: { headers: { [name: string]: unknown; @@ -1742,7 +2059,7 @@ export interface operations { }; content?: never; }; - /** @description One or both players from this game are not registered (/reg) OR Tournament not found OR Game not found in this tournament */ + /** @description Game not found in this tournament OR One or both players from this game are not registered (/reg) */ 404: { headers: { [name: string]: unknown; @@ -1785,7 +2102,7 @@ export interface operations { }; }; }; - /** @description This game was finished before score corrections were supported OR Group games cannot be corrected after qualification started OR This game is not finished yet OR Cannot correct games of an inactive/archived tournament! */ + /** @description The winner cannot change: the next qualification match of these players has already started OR This game is not finished yet OR This game was finished before score corrections were supported OR Group games cannot be corrected after qualification started OR Cannot correct games of an inactive/archived tournament! */ 400: { headers: { [name: string]: unknown; @@ -1799,7 +2116,7 @@ export interface operations { }; content?: never; }; - /** @description One or both players from this game are not registered (/reg) OR Tournament not found OR Game not found in this tournament */ + /** @description Game not found in this tournament OR One or both players from this game are not registered (/reg) OR Tournament not found */ 404: { headers: { [name: string]: unknown; @@ -1861,7 +2178,7 @@ export interface operations { }; content?: never; }; - /** @description Tournament not found OR Game not found in this tournament */ + /** @description Game not found in this tournament */ 404: { headers: { [name: string]: unknown; @@ -1883,7 +2200,6 @@ export interface operations { export enum PathsSetStatusPostParametersQueryStatus { beginner = "beginner", advanced = "advanced", - admin = "admin", } export enum PathsRegGamePostParametersQueryTip { val = "val", diff --git a/src/app/route-tree.gen.ts b/src/app/route-tree.gen.ts index abf6d162..e96ae759 100644 --- a/src/app/route-tree.gen.ts +++ b/src/app/route-tree.gen.ts @@ -56,7 +56,6 @@ import { Route as With_menuWhen2meetNewRouteImport } from "./routes/_with_menu/w import { Route as With_menuTabletennisTournamentsRouteImport } from "./routes/_with_menu/tabletennis/tournaments"; import { Route as With_menuTabletennisPlayersRouteImport } from "./routes/_with_menu/tabletennis/players"; import { Route as With_menuTabletennisMatchesRouteImport } from "./routes/_with_menu/tabletennis/matches"; -import { Route as With_menuTabletennisEventsRouteImport } from "./routes/_with_menu/tabletennis/events"; import { Route as With_menuStudentAffairsSignOutRouteImport } from "./routes/_with_menu/student-affairs/sign-out"; import { Route as With_menuStudentAffairsSignInRouteImport } from "./routes/_with_menu/student-affairs/sign-in"; import { Route as With_menuSportTrainerRouteImport } from "./routes/_with_menu/sport/trainer"; @@ -350,12 +349,6 @@ const With_menuTabletennisMatchesRoute = path: "/tabletennis/matches", getParentRoute: () => With_menuRouteRoute, } as any); -const With_menuTabletennisEventsRoute = - With_menuTabletennisEventsRouteImport.update({ - id: "/tabletennis/events", - path: "/tabletennis/events", - getParentRoute: () => With_menuRouteRoute, - } as any); const With_menuStudentAffairsSignOutRoute = With_menuStudentAffairsSignOutRouteImport.update({ id: "/student-affairs/sign-out", @@ -657,7 +650,6 @@ export interface FileRoutesByFullPath { "/sport/trainer": typeof With_menuSportTrainerRoute; "/student-affairs/sign-in": typeof With_menuStudentAffairsSignInRoute; "/student-affairs/sign-out": typeof With_menuStudentAffairsSignOutRoute; - "/tabletennis/events": typeof With_menuTabletennisEventsRoute; "/tabletennis/matches": typeof With_menuTabletennisMatchesRoute; "/tabletennis/players": typeof With_menuTabletennisPlayersRoute; "/tabletennis/tournaments": typeof With_menuTabletennisTournamentsRoute; @@ -748,7 +740,6 @@ export interface FileRoutesByTo { "/sport/trainer": typeof With_menuSportTrainerRoute; "/student-affairs/sign-in": typeof With_menuStudentAffairsSignInRoute; "/student-affairs/sign-out": typeof With_menuStudentAffairsSignOutRoute; - "/tabletennis/events": typeof With_menuTabletennisEventsRoute; "/tabletennis/matches": typeof With_menuTabletennisMatchesRoute; "/tabletennis/players": typeof With_menuTabletennisPlayersRoute; "/tabletennis/tournaments": typeof With_menuTabletennisTournamentsRoute; @@ -844,7 +835,6 @@ export interface FileRoutesById { "/_with_menu/sport/trainer": typeof With_menuSportTrainerRoute; "/_with_menu/student-affairs/sign-in": typeof With_menuStudentAffairsSignInRoute; "/_with_menu/student-affairs/sign-out": typeof With_menuStudentAffairsSignOutRoute; - "/_with_menu/tabletennis/events": typeof With_menuTabletennisEventsRoute; "/_with_menu/tabletennis/matches": typeof With_menuTabletennisMatchesRoute; "/_with_menu/tabletennis/players": typeof With_menuTabletennisPlayersRoute; "/_with_menu/tabletennis/tournaments": typeof With_menuTabletennisTournamentsRoute; @@ -940,7 +930,6 @@ export interface FileRouteTypes { | "/sport/trainer" | "/student-affairs/sign-in" | "/student-affairs/sign-out" - | "/tabletennis/events" | "/tabletennis/matches" | "/tabletennis/players" | "/tabletennis/tournaments" @@ -1031,7 +1020,6 @@ export interface FileRouteTypes { | "/sport/trainer" | "/student-affairs/sign-in" | "/student-affairs/sign-out" - | "/tabletennis/events" | "/tabletennis/matches" | "/tabletennis/players" | "/tabletennis/tournaments" @@ -1126,7 +1114,6 @@ export interface FileRouteTypes { | "/_with_menu/sport/trainer" | "/_with_menu/student-affairs/sign-in" | "/_with_menu/student-affairs/sign-out" - | "/_with_menu/tabletennis/events" | "/_with_menu/tabletennis/matches" | "/_with_menu/tabletennis/players" | "/_with_menu/tabletennis/tournaments" @@ -1512,13 +1499,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof With_menuTabletennisMatchesRouteImport; parentRoute: typeof With_menuRouteRoute; }; - "/_with_menu/tabletennis/events": { - id: "/_with_menu/tabletennis/events"; - path: "/tabletennis/events"; - fullPath: "/tabletennis/events"; - preLoaderRoute: typeof With_menuTabletennisEventsRouteImport; - parentRoute: typeof With_menuRouteRoute; - }; "/_with_menu/student-affairs/sign-out": { id: "/_with_menu/student-affairs/sign-out"; path: "/student-affairs/sign-out"; @@ -1894,7 +1874,6 @@ interface With_menuRouteRouteChildren { With_menuSportTrainerRoute: typeof With_menuSportTrainerRoute; With_menuStudentAffairsSignInRoute: typeof With_menuStudentAffairsSignInRoute; With_menuStudentAffairsSignOutRoute: typeof With_menuStudentAffairsSignOutRoute; - With_menuTabletennisEventsRoute: typeof With_menuTabletennisEventsRoute; With_menuTabletennisMatchesRoute: typeof With_menuTabletennisMatchesRoute; With_menuTabletennisPlayersRoute: typeof With_menuTabletennisPlayersRoute; With_menuTabletennisTournamentsRoute: typeof With_menuTabletennisTournamentsRoute; @@ -1972,7 +1951,6 @@ const With_menuRouteRouteChildren: With_menuRouteRouteChildren = { With_menuSportTrainerRoute: With_menuSportTrainerRoute, With_menuStudentAffairsSignInRoute: With_menuStudentAffairsSignInRoute, With_menuStudentAffairsSignOutRoute: With_menuStudentAffairsSignOutRoute, - With_menuTabletennisEventsRoute: With_menuTabletennisEventsRoute, With_menuTabletennisMatchesRoute: With_menuTabletennisMatchesRoute, With_menuTabletennisPlayersRoute: With_menuTabletennisPlayersRoute, With_menuTabletennisTournamentsRoute: With_menuTabletennisTournamentsRoute, diff --git a/src/app/routes/_with_menu/tabletennis/events.tsx b/src/app/routes/_with_menu/tabletennis/events.tsx deleted file mode 100644 index be768b23..00000000 --- a/src/app/routes/_with_menu/tabletennis/events.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Helmet } from "@dr.pogodin/react-helmet"; -import { TabletennisTabs } from "@/components/tabletennis/TabletennisTabs"; -import { Topbar } from "@/components/layout/Topbar.tsx"; - -export const Route = createFileRoute("/_with_menu/tabletennis/events")({ - component: RouteComponent, -}); - -function RouteComponent() { - return ( - <> - - Table tennis - - - - - -
- -

- Events will be created soon -

-

- Club events, open trainings and friendly matches will appear here. - Stay tuned! -

-
- - ); -} diff --git a/src/app/routes/_with_menu/tabletennis/tournaments.tsx b/src/app/routes/_with_menu/tabletennis/tournaments.tsx index 6cf5ccc5..cf995966 100644 --- a/src/app/routes/_with_menu/tabletennis/tournaments.tsx +++ b/src/app/routes/_with_menu/tabletennis/tournaments.tsx @@ -1,14 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; import { Helmet } from "@dr.pogodin/react-helmet"; import { TabletennisTabs } from "@/components/tabletennis/TabletennisTabs"; -import { TabletennisTournaments } from "@/components/tabletennis/TabletennisTournaments"; +import { + type TournamentStage, + TabletennisTournaments, +} from "@/components/tabletennis/TabletennisTournaments"; import { Topbar } from "@/components/layout/Topbar.tsx"; export const Route = createFileRoute("/_with_menu/tabletennis/tournaments")({ component: RouteComponent, + validateSearch: ( + search: Record, + ): { tour?: string; stage?: TournamentStage } => { + return { + tour: search.tour ? search.tour.toString() : undefined, + stage: search.stage === "qualification" ? "qualification" : undefined, + }; + }, }); function RouteComponent() { + const { tour, stage } = Route.useSearch(); + return ( <> @@ -18,7 +31,7 @@ function RouteComponent() { - + ); } diff --git a/src/components/tabletennis/ArchivedTournament.tsx b/src/components/tabletennis/ArchivedTournament.tsx new file mode 100644 index 00000000..0e91299c --- /dev/null +++ b/src/components/tabletennis/ArchivedTournament.tsx @@ -0,0 +1,253 @@ +import { useMemo, useState } from "react"; +import { cn } from "@/lib/ui/cn"; +import { $tabletennis } from "@/api/tabletennis"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { useToast } from "@/components/toast"; +import { ValidationMatches } from "./ValidationMatches"; +import { QualificationMatches } from "./QualificationMatches"; +import type { GameData, Groups, Player } from "./bracket-utils"; +import { formatDay } from "./format"; +import { DeleteTourButton } from "./LiveTournament"; + +export type ArchivedTour = { + id: string; + name: string; + date: string; + players: string[]; + games: { + val_game_ids: string[] | null; + cval_game_ids: string[] | null; + }; + qual_top?: Record; + groups?: Groups; + qual_seeding?: string[]; +}; + +const noop = async () => {}; + +/** + * Read-only view of a finished tournament: the group tables and the + * qualification bracket, drawn by the same components as a live tournament. + * An admin can reopen it to correct games and standings. + */ +export function ArchivedTournament({ + tour, + players, + isAdmin, + onBack, + onReopened, + onDeleted, +}: { + tour: ArchivedTour; + players: Player[]; + isAdmin: boolean; + onBack: () => void; + onReopened: () => Promise; + onDeleted: () => void; +}) { + const [mode, setMode] = useState<"validation" | "qualification">( + "validation", + ); + const { showError, showConfirm } = useToast(); + const { mutateAsync: reopenTour, isPending: reopening } = + $tabletennis.useMutation("post", "/reopen-tour", { + onError: (e) => showError("Error", formatApiErrorMessage(e)), + }); + + async function handleReopen() { + const ok = await showConfirm({ + title: "Reopen tournament", + message: `${tour.name} becomes active again, so its games and standings can be corrected. Finish it again when you are done.`, + confirmText: "Reopen", + cancelText: "Cancel", + type: "warning", + }); + if (!ok) return; + try { + await reopenTour({ params: { query: { tour_id: tour.id } } }); + await onReopened(); + } catch { + // toast shown by onError + } + } + + const { valIds, cvalIds, ids } = useMemo(() => { + const valIds = tour.games.val_game_ids ?? []; + const cvalIds = tour.games.cval_game_ids ?? []; + return { valIds, cvalIds, ids: [...valIds, ...cvalIds] }; + }, [tour.games]); + + const { data, isPending } = $tabletennis.useQuery( + "get", + "/get-games-by-id", + { params: { query: { ids } } }, + { enabled: ids.length > 0 }, + ); + + const { validationGames, qualificationGames } = useMemo(() => { + const games = + (data as { games?: GameData[] } | undefined)?.games ?? ([] as GameData[]); + const val = new Set(valIds); + const cval = new Set(cvalIds); + return { + validationGames: games.filter((g) => val.has(g.game_id)), + qualificationGames: games.filter((g) => cval.has(g.game_id)), + }; + }, [data, valIds, cvalIds]); + + const tourPlayers = useMemo(() => { + const inTour = new Set(tour.players); + return players.filter((p) => inTour.has(p.id)); + }, [players, tour.players]); + + const groups = tour.groups ?? {}; + const hasGroups = Object.keys(groups).length > 0; + const seeding = tour.qual_seeding ?? []; + + return ( +
+
+ +
+

{tour.name}

+ {tour.date && ( +

+ {formatDay(tour.date)} · {tour.players.length} players +

+ )} +
+ {isAdmin && + (ids.length === 0 || !isPending) && + ![...validationGames, ...qualificationGames].some( + (g) => g.finished, + ) && } + {isAdmin && ( + + )} +
+ +
+ {(["validation", "qualification"] as const).map((m) => ( + + ))} +
+ + {ids.length > 0 && isPending ? ( +
+ ) : mode === "validation" ? ( + hasGroups ? ( + 0} + validationGames={validationGames} + isAdmin={false} + onChanged={noop} + /> + ) : ( + + ) + ) : seeding.length > 0 ? ( + + ) : ( + + )} +
+ ); +} + +/** games of older tournaments that were played before groups and the bracket existed */ +function PlainGames({ games, empty }: { games: GameData[]; empty: string }) { + if (games.length === 0) { + return ( +

+ {empty} +

+ ); + } + return ( +
+ {games.map((g) => { + const p1Won = g.player1.score > g.player2.score; + return ( +
+ + {g.player1.nickname ?? g.player1.innohassle_id} + + + {g.finished ? `${g.player1.score} : ${g.player2.score}` : "—"} + + + {g.player2.nickname ?? g.player2.innohassle_id} + +
+ ); + })} +
+ ); +} diff --git a/src/components/tabletennis/BracketGraph.tsx b/src/components/tabletennis/BracketGraph.tsx index a0a8827c..bbefd652 100644 --- a/src/components/tabletennis/BracketGraph.tsx +++ b/src/components/tabletennis/BracketGraph.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import { cn } from "@/lib/ui/cn"; import { type BracketMatch, @@ -6,8 +7,8 @@ import { roundLabel, } from "./bracket-utils"; -const CARD_W = 164; -const CARD_H = 64; +export const CARD_W = 164; +export const CARD_H = 64; const COL_GAP = 36; const ROW_GAP = 14; const HEADER_H = 28; @@ -128,11 +129,17 @@ export function BracketGraph({ matches, getName, onSelect, + renderCard, }: { section: BracketSection; matches: Map; getName: (id: string) => string; onSelect?: (match: BracketMatch) => void; + /** replaces the default card; `style` positions it in the graph */ + renderCard?: ( + match: BracketMatch, + style: React.CSSProperties, + ) => React.ReactNode; }) { const rounds = section.matchIds.map((ids) => ids.map((id) => matches.get(id)!), @@ -203,16 +210,23 @@ export function BracketGraph({ ms.some((m) => m.status === "bye"), )}
- {ms.map((m, i) => ( - - ))} + {ms.map((m, i) => { + const style = { left: colX(r), top: centerY(r, i) - CARD_H / 2 }; + return renderCard ? ( + + {renderCard(m, { position: "absolute", ...style })} + + ) : ( + + ); + })} ))} diff --git a/src/components/tabletennis/CreateTournamentForm.tsx b/src/components/tabletennis/CreateTournamentForm.tsx new file mode 100644 index 00000000..378e994e --- /dev/null +++ b/src/components/tabletennis/CreateTournamentForm.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { $tabletennis } from "@/api/tabletennis"; +import { + formatApiErrorMessage, + isApiHttpError, +} from "@/api/helpers/create-query-client"; +import { useToast } from "@/components/toast"; +import { + type PlayerOption, + PlayersField, + failedPlayersOf, + usePlayersField, +} from "./PlayersField"; + +/** "2026-10-09" in the viewer's time zone, the value format of */ +function todayInputValue(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function CreateTournamentForm({ + options, + onCreated, + onCancel, +}: { + options: PlayerOption[]; + onCreated: (tourId: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [tourDate, setTourDate] = useState(""); + const field = usePlayersField(options); + const { showError } = useToast(); + + const { mutate: createTour, isPending: creating } = $tabletennis.useMutation( + "post", + "/reg-tour", + { + onSuccess: (data) => + onCreated((data as { tournament_id: string }).tournament_id), + onError: (error) => { + const failed = failedPlayersOf(error); + if (failed) { + field.applyFailed(failed); + showError( + "Tournament not created", + "Some players were not found, see the list below", + ); + } else if (isApiHttpError(error) && error.httpCode === 403) { + showError( + "Access denied", + "Only administrators can create tournaments", + ); + } else { + showError("Error", formatApiErrorMessage(error)); + } + }, + }, + ); + + function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim() || field.count < 2) return; + const body = field.prepare(); + if (!body) return; + // without a date (or with today's) the backend stamps the current time; a past date is + // only shown in the archive, the rating is still counted in the order games are played + const date = + tourDate && tourDate !== todayInputValue() + ? `${tourDate}T12:00:00Z` + : undefined; + createTour({ + params: { query: { name: name.trim(), date } }, + body, + }); + } + + return ( +
+
+

Create tournament

+
+ setName(e.target.value)} + placeholder="Tournament name" + className="input input-bordered w-full" + maxLength={100} + required + /> + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/tabletennis/LiveTournament.tsx b/src/components/tabletennis/LiveTournament.tsx new file mode 100644 index 00000000..85284f51 --- /dev/null +++ b/src/components/tabletennis/LiveTournament.tsx @@ -0,0 +1,405 @@ +import { useMemo, useState } from "react"; +import { keepPreviousData } from "@tanstack/react-query"; +import { cn } from "@/lib/ui/cn"; +import { $tabletennis } from "@/api/tabletennis"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { useToast } from "@/components/toast"; +import { Modal } from "@/components/common/Modal.tsx"; +import { ValidationMatches } from "./ValidationMatches"; +import { QualificationMatches } from "./QualificationMatches"; +import { + type GameData, + resolveBracket, + standingsToTop, + topsEqual, +} from "./bracket-utils"; +import { + type PlayerOption, + PlayersField, + failedPlayersOf, + usePlayersField, +} from "./PlayersField"; +import { formatDay } from "./format"; +import { LIVE_REFETCH_MS } from "./useTabletennisRefresh"; + +export type TournamentStage = "validation" | "qualification"; + +export type TourData = { + id: string; + name: string; + date: string; + players: string[]; + active?: boolean; + games: { + val_game_ids: string[] | null; + cval_game_ids: string[] | null; + total_count: number; + }; + qual_top?: Record; + val_top?: Record; + groups?: Record; + groups_locked?: boolean; + qual_seeding?: string[]; +}; + +export function LiveTournament({ + tour, + allPlayers, + isAdmin, + stage, + onStageChange, + onBack, + onFinished, + onDeleted, + onChanged, +}: { + tour: TourData; + allPlayers: PlayerOption[]; + isAdmin: boolean; + stage: TournamentStage; + onStageChange: (stage: TournamentStage) => void; + onBack: () => void; + onFinished: () => void; + onDeleted: () => void; + onChanged: () => Promise; +}) { + const [showAddPlayers, setShowAddPlayers] = useState(false); + const [showEndConfirm, setShowEndConfirm] = useState(false); + const addField = usePlayersField(allPlayers); + const { showError } = useToast(); + + const gameIds = useMemo( + () => [ + ...(tour.games.val_game_ids ?? []), + ...(tour.games.cval_game_ids ?? []), + ], + [tour.games], + ); + + const { data: gamesByIdData, isPending: gamesPending } = + $tabletennis.useQuery( + "get", + "/get-games-by-id", + { params: { query: { ids: gameIds } } }, + { + enabled: gameIds.length > 0, + refetchInterval: LIVE_REFETCH_MS, + placeholderData: keepPreviousData, + }, + ); + + const { mutate: finishTour, isPending: finishing } = $tabletennis.useMutation( + "post", + "/finish-tour", + { + onSuccess: onFinished, + onError: (error) => showError("Error", formatApiErrorMessage(error)), + }, + ); + + const { mutate: addPlayers, isPending: addingPlayers } = + $tabletennis.useMutation("post", "/reg-tour/add-player", { + onSuccess: () => { + onChanged(); + setShowAddPlayers(false); + addField.reset(); + }, + onError: (error) => { + const failed = failedPlayersOf(error); + if (failed) addField.applyFailed(failed); + else showError("Error", formatApiErrorMessage(error)); + }, + }); + + const { mutateAsync: changeQualTop } = $tabletennis.useMutation( + "post", + "/reg-tour/change-qual-top", + { onError: (error) => showError("Error", formatApiErrorMessage(error)) }, + ); + + const tourPlayers = useMemo(() => { + const inTour = new Set(tour.players); + return allPlayers.filter((p) => inTour.has(p.id)); + }, [allPlayers, tour.players]); + + const addablePlayers = useMemo(() => { + const inTour = new Set(tour.players); + return allPlayers.filter((p) => !inTour.has(p.id)); + }, [allPlayers, tour.players]); + + const { validationGames, qualificationGames } = useMemo(() => { + const games = (gamesByIdData as { games?: GameData[] } | undefined)?.games; + const val = new Set(tour.games.val_game_ids ?? []); + const cval = new Set(tour.games.cval_game_ids ?? []); + return { + validationGames: (games ?? []).filter((g) => val.has(g.game_id)), + qualificationGames: (games ?? []).filter((g) => cval.has(g.game_id)), + }; + }, [gamesByIdData, tour.games]); + + const groupsLocked = tour.groups_locked ?? false; + const canDelete = + (gameIds.length === 0 || !gamesPending) && + ![...validationGames, ...qualificationGames].some((g) => g.finished); + const seeding = useMemo(() => tour.qual_seeding ?? [], [tour.qual_seeding]); + + const qualProgress = useMemo(() => { + if (seeding.length === 0) return null; + const bracket = resolveBracket(seeding, qualificationGames); + const left = [...bracket.matches.values()].filter( + (m) => m.status === "ready" || m.status === "waiting", + ).length; + return { bracket, left }; + }, [seeding, qualificationGames]); + + async function handleEndTournament() { + // store every place decided so far before the tournament is archived, + // so a tournament ended early still shows the places that were played out + if (qualProgress && qualProgress.bracket.places.length > 0) { + const top = standingsToTop(qualProgress.bracket.places); + if (!topsEqual(top, tour.qual_top ?? {})) { + try { + await changeQualTop({ + params: { query: { tour_id: tour.id } }, + body: top, + }); + } catch { + return; // toast shown by onError, keep the tournament open + } + } + } + finishTour({ params: { query: { tour_id: tour.id } } }); + } + + return ( + <> +
+
+ +
+

{tour.name}

+

+ {tour.date && `${formatDay(tour.date)} · `} + {tour.players.length} players +

+
+ {isAdmin && !groupsLocked && ( + + )} + {isAdmin && canDelete && ( + + )} + {isAdmin && ( + + )} +
+ +
+ {(["validation", "qualification"] as const).map((s) => ( + + ))} +
+ + {showAddPlayers && !groupsLocked && ( +
+ +
+ + +
+
+ )} + + {stage === "validation" && ( + 0} + validationGames={validationGames} + isAdmin={isAdmin} + onChanged={onChanged} + onGoToQualification={() => onStageChange("qualification")} + /> + )} + {stage === "qualification" && ( + + )} +
+ + +

+ Are you sure you want to finish {tour.name}? It can + be reopened from the archive later. +

+ {!qualProgress && ( +

+ Qualification has not started, no final standings will be saved. +

+ )} + {qualProgress && !qualProgress.bracket.complete && ( +

+ Qualification is not finished: {qualProgress.left} match + {qualProgress.left === 1 ? "" : "es"} left. Only the places decided + so far ({qualProgress.bracket.places.length} of {seeding.length}) + will be saved, and no bonuses are granted. +

+ )} +
+ + +
+
+ + ); +} + +/** deletes a tournament created by mistake; the server refuses once a game is finished */ +export function DeleteTourButton({ + tour, + onDeleted, +}: { + tour: { id: string; name: string }; + onDeleted: () => void; +}) { + const { showError, showConfirm } = useToast(); + const { mutateAsync: deleteTour, isPending } = $tabletennis.useMutation( + "post", + "/delete-tour", + { onError: (e) => showError("Error", formatApiErrorMessage(e)) }, + ); + + async function handleDelete() { + const ok = await showConfirm({ + title: "Delete tournament", + message: `Delete ${tour.name}? No game has been played yet, so no rating changes. This cannot be undone.`, + confirmText: "Delete", + cancelText: "Keep", + type: "warning", + }); + if (!ok) return; + try { + await deleteTour({ params: { query: { tour_id: tour.id } } }); + onDeleted(); + } catch { + // toast shown by onError + } + } + + return ( + + ); +} diff --git a/src/components/tabletennis/MergeGuestModal.tsx b/src/components/tabletennis/MergeGuestModal.tsx new file mode 100644 index 00000000..dd19ba78 --- /dev/null +++ b/src/components/tabletennis/MergeGuestModal.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState } from "react"; +import { $tabletennis, tabletennisTypes } from "@/api/tabletennis"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { Modal } from "@/components/common/Modal.tsx"; +import { useToast } from "@/components/toast"; +import { useTabletennisRefresh } from "./useTabletennisRefresh"; + +type SchemaPlayer = tabletennisTypes.SchemaPlayer; + +/** Hands a guest over to a registered player: by a picked nickname or by email */ +export function MergeGuestModal({ + open, + onOpenChange, + guest, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + guest: SchemaPlayer; +}) { + const [query, setQuery] = useState(""); + const [picked, setPicked] = useState(null); + const refresh = useTabletennisRefresh(); + const { showSuccess, showError } = useToast(); + + const { data } = $tabletennis.useQuery("get", "/players"); + const registered = useMemo( + () => + ( + (data as unknown as { players?: SchemaPlayer[] } | undefined) + ?.players ?? [] + ).filter((p) => !p.placeholder), + [data], + ); + + const isEmail = query.includes("@"); + const suggestions = useMemo(() => { + const lower = query.trim().toLowerCase(); + if (!lower || isEmail || picked) return []; + return registered + .filter((p) => p.nickname.toLowerCase().includes(lower)) + .slice(0, 8); + }, [registered, query, isEmail, picked]); + + const { mutate, isPending } = $tabletennis.useMutation( + "post", + "/merge-guest", + { + onSuccess: (res) => { + const tours = (res as { tournaments?: number }).tournaments ?? 0; + showSuccess( + "Guest linked", + `${guest.nickname} → ${picked?.nickname ?? query.trim()}, ${tours} tournament${tours === 1 ? "" : "s"} moved`, + ); + refresh(); + handleOpenChange(false); + }, + onError: (e) => showError("Error", formatApiErrorMessage(e)), + }, + ); + + function handleOpenChange(value: boolean) { + if (!value) { + setQuery(""); + setPicked(null); + } + onOpenChange(value); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const target = picked + ? { player_id: picked.innohassle_id } + : isEmail + ? { email: query.trim() } + : null; + if (!target) return; + mutate({ params: { query: { guest_id: guest.innohassle_id, ...target } } }); + } + + return ( + +
+

+ Pick the registered player who played as{" "} + {guest.nickname}. They get the guest's games in every + tournament and the rating ({guest.rating}); the guest is deleted. +

+
+ { + setPicked(null); + setQuery(e.target.value); + }} + placeholder="Nickname or email" + className="input input-bordered h-12 w-full" + /> + {suggestions.length > 0 && ( +
+ {suggestions.map((p) => ( + + ))} +
+ )} +
+

+ The person has to register in table tennis first. +

+
+ + +
+
+
+ ); +} diff --git a/src/components/tabletennis/PlayerActionMenu.tsx b/src/components/tabletennis/PlayerActionMenu.tsx index 31e85bd0..9d5fd925 100644 --- a/src/components/tabletennis/PlayerActionMenu.tsx +++ b/src/components/tabletennis/PlayerActionMenu.tsx @@ -1,4 +1,16 @@ import { useState } from "react"; +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; import { $tabletennis, tabletennisTypes } from "@/api/tabletennis"; import { formatApiErrorMessage, @@ -6,35 +18,54 @@ import { } from "@/api/helpers/create-query-client"; import { Modal } from "@/components/common/Modal.tsx"; import { useToast } from "@/components/toast"; -import { useQueryClient } from "@tanstack/react-query"; +import { MergeGuestModal } from "./MergeGuestModal"; +import { useTabletennisRefresh } from "./useTabletennisRefresh"; type SchemaPlayer = tabletennisTypes.SchemaPlayer; -/** RTTF: a starting rating is a multiple of 25 and at least 100 */ -const MIN_START_RATING = 100; -const START_RATING_STEP = 25; +/** a beginner reaching this rating becomes advanced automatically (backend) */ +const ADVANCED_RATING = 350; + +/** a whole number from 0, or null */ +function parseRating(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + return Number.isSafeInteger(value) ? value : null; +} export function PlayerActionMenu({ player, }: { player: SchemaPlayer & { is_active: boolean }; }) { + const [menuOpen, setMenuOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [ratingOpen, setRatingOpen] = useState(false); - const [rating, setRating] = useState(MIN_START_RATING); - const queryClient = useQueryClient(); + const [ratingInput, setRatingInput] = useState(""); + const [mergeOpen, setMergeOpen] = useState(false); + const refresh = useTabletennisRefresh(); const { showSuccess, showError } = useToast(); + // the menu is rendered in a portal, so it floats over the page instead of + // stretching the scrollable table it is opened from + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-end", + open: menuOpen, + onOpenChange: setMenuOpen, + middleware: [offset(4), flip({ padding: 8 }), shift({ padding: 8 })], + whileElementsMounted: autoUpdate, + }); + const { getReferenceProps, getFloatingProps } = useInteractions([ + useClick(context), + useDismiss(context), + useRole(context, { role: "menu" }), + ]); + const { mutate, isPending } = $tabletennis.useMutation("post", "/set-status"); const { mutate: setRatingMutate, isPending: settingRating } = $tabletennis.useMutation("post", "/set-rating"); - function refreshPlayers() { - queryClient.invalidateQueries({ - queryKey: $tabletennis.queryOptions("get", "/players").queryKey, - }); - } - function handleError(error: unknown, action: string) { if (isApiHttpError(error) && error.httpCode === 403) { showError("Access denied", `Only administrators can ${action}`); @@ -43,17 +74,18 @@ export function PlayerActionMenu({ } } - const currentStatus = player.status.toLowerCase(); - // beginner (or no status yet) can be upgraded, advanced can be downgraded back - const targetStatus: "advanced" | "beginner" | null = - currentStatus === "beginner" || currentStatus === "" - ? "advanced" - : currentStatus === "advanced" - ? "beginner" - : null; + // advanced can be downgraded back, everyone else can be upgraded + const targetStatus: "advanced" | "beginner" = + player.status.toLowerCase() === "advanced" ? "beginner" : "advanced"; + + const rating = parseRating(ratingInput); + + function openFromMenu(open: (value: boolean) => void) { + setMenuOpen(false); + open(true); + } function handleConfirm() { - if (!targetStatus) return; mutate( { params: { @@ -70,7 +102,7 @@ export function PlayerActionMenu({ "Status updated", `${player.nickname} is now ${targetStatus}`, ); - refreshPlayers(); + refresh(); setConfirmOpen(false); }, onError: (error) => handleError(error, "change player status"), @@ -79,12 +111,13 @@ export function PlayerActionMenu({ } function openRating() { - const current = Math.round(player.rating / START_RATING_STEP); - setRating(Math.max(MIN_START_RATING, current * START_RATING_STEP)); - setRatingOpen(true); + setRatingInput(String(player.rating)); + openFromMenu(setRatingOpen); } - function handleSetRating() { + function handleSetRating(e: React.FormEvent) { + e.preventDefault(); + if (rating === null || rating === player.rating) return; setRatingMutate( { params: { query: { innohassle_id: player.innohassle_id, rating } }, @@ -92,7 +125,7 @@ export function PlayerActionMenu({ { onSuccess: () => { showSuccess("Rating updated", `${player.nickname}: ${rating}`); - refreshPlayers(); + refresh(); setRatingOpen(false); }, onError: (error) => handleError(error, "change player rating"), @@ -102,45 +135,63 @@ export function PlayerActionMenu({ return ( <> -
- -
    - {targetStatus === "advanced" && ( + + + {menuOpen && ( + +
    • -
    • - )} - {targetStatus === "beginner" && (
    • -
    • - )} - {targetStatus === null && ( -
    • - - Already {player.status.toLowerCase()} - -
    • - )} -
    • - -
    • -
    -
+ {player.placeholder && ( +
  • + +
  • + )} + + + )} - -

    - RTTF: the organiser assigns a newcomer's starting rating, a - multiple of 25 and at least 100. Current rating of{" "} - {player.nickname}: {player.rating}. -

    -
    - {[-100, -25].map((delta) => ( + {player.placeholder && ( + + )} + + +
    +

    + Current rating of {player.nickname}:{" "} + {player.rating}. Enter any whole number from 0. +

    + setRatingInput(e.target.value)} + aria-label="New rating" + className="input input-bordered mt-3 h-14 w-full text-center text-3xl font-semibold tabular-nums" + /> + {ratingInput.trim() !== "" && rating === null && ( +

    + The rating must be a whole number from 0. +

    + )} + {targetStatus === "advanced" && + rating !== null && + player.rating < ADVANCED_RATING && + rating >= ADVANCED_RATING && ( +

    + {player.nickname} will become Advanced ({ADVANCED_RATING}+). +

    + )} +
    - ))} - - {rating} - - {[25, 100].map((delta) => ( - ))} -
    -
    - - -
    +
    +
    ); diff --git a/src/components/tabletennis/PlayersField.tsx b/src/components/tabletennis/PlayersField.tsx new file mode 100644 index 00000000..d55f7c3d --- /dev/null +++ b/src/components/tabletennis/PlayersField.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/ui/cn"; +import { $tabletennis } from "@/api/tabletennis"; +import { formatApiErrorMessage } from "@/api/helpers/create-query-client"; +import { useToast } from "@/components/toast"; +import { useTabletennisRefresh } from "./useTabletennisRefresh"; + +export type PlayerOption = { + id: string; + name: string; + rating: number; + guest: boolean; +}; + +export type FailedPlayer = { identifier: string; code: string; reason: string }; + +type Unresolved = { key: number; text: string; code: string; reason: string }; + +function splitTokens(raw: string): string[] { + return raw + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** players already written in the input (every token except the one being typed) */ +function typedTokens(raw: string): Set { + return new Set( + raw + .split(/[\n,]+/) + .slice(0, -1) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean), + ); +} + +/** a guest is named after the typed text, or the part of the email before @ */ +function guestNickname(text: string): string { + return text.trim().split("@")[0]!.trim().slice(0, 20); +} + +/** the players listed in a 400 from /reg-tour or /reg-tour/add-player */ +export function failedPlayersOf(error: unknown): FailedPlayer[] | null { + const detail = (error as { body?: { detail?: unknown } } | null)?.body + ?.detail; + if (typeof detail !== "object" || detail === null) return null; + const failed = (detail as { failed?: unknown }).failed; + return Array.isArray(failed) && failed.length > 0 + ? (failed as FailedPlayer[]) + : null; +} + +export type PlayersFieldState = ReturnType; + +/** + * State of a players field. `known` are all players the typed text is matched against: + * a nickname typed by hand counts as picked when exactly one player has it. + */ +export function usePlayersField(known: PlayerOption[]) { + const [raw, setRaw] = useState(""); + const [nickToId, setNickToId] = useState>(new Map()); + const [picked, setPicked] = useState([]); + const [unresolved, setUnresolved] = useState([]); + const nextKey = useRef(0); + + const byNick = useMemo(() => { + const map = new Map(); + for (const p of known) { + const key = p.name.trim().toLowerCase(); + map.set(key, [...(map.get(key) ?? []), p]); + } + return map; + }, [known]); + + const items = [ + ...splitTokens(raw), + ...unresolved.map((u) => u.text.trim()).filter(Boolean), + ]; + + function matchesOf(item: string): PlayerOption[] { + return byNick.get(item.trim().toLowerCase()) ?? []; + } + + function idOf(item: string): string { + if (item.includes("@")) return item; + const mapped = nickToId.get(item); + if (mapped) return mapped; + const matches = matchesOf(item); + return matches.length === 1 ? matches[0]!.id : item; + } + + function isAmbiguous(item: string) { + return ( + !item.includes("@") && !nickToId.has(item) && matchesOf(item).length > 1 + ); + } + + /** + * The request body, or null when some nicknames belong to several players: + * those are moved to rows where the right player is picked. + */ + function prepare() { + const ambiguous = items.filter(isAmbiguous); + if (ambiguous.length > 0) { + setRaw( + splitTokens(raw) + .filter((t) => !isAmbiguous(t)) + .map((t) => `${t},\n`) + .join(""), + ); + setUnresolved((prev) => [ + ...prev.filter((u) => !isAmbiguous(u.text.trim())), + ...ambiguous.map((text) => ({ + key: nextKey.current++, + text, + code: "ambiguous", + reason: "Several players have this nickname, pick one", + })), + ]); + return null; + } + const player_ids = picked.map((p) => p.id); + const emails: string[] = []; + for (const item of items) { + if (item.includes("@")) emails.push(item); + else player_ids.push(idOf(item)); + } + return { player_ids, emails }; + } + + /** moves the failed players out of the text into rows that can be fixed one by one */ + function applyFailed(failed: FailedPlayer[]) { + const failedIds = new Set(failed.map((f) => f.identifier.toLowerCase())); + const isFailed = (item: string) => failedIds.has(idOf(item).toLowerCase()); + setRaw( + items + .filter((t) => !isFailed(t)) + .map((t) => `${t},\n`) + .join(""), + ); + setPicked((prev) => prev.filter((p) => !failedIds.has(p.id.toLowerCase()))); + setUnresolved( + failed.map((f) => ({ + key: nextKey.current++, + text: + items.find( + (t) => idOf(t).toLowerCase() === f.identifier.toLowerCase(), + ) ?? + picked.find((p) => p.id.toLowerCase() === f.identifier.toLowerCase()) + ?.name ?? + f.identifier, + code: f.code, + reason: f.reason, + })), + ); + } + + /** a suggestion picked while typing: replaces the token being typed */ + function pickSuggestion(player: PlayerOption) { + setRaw((prev) => { + const tokens = prev.split(/[\n,]+/); + const typed = tokens[tokens.length - 1] ?? ""; + const rest = prev.slice(0, prev.length - typed.length); + // a nickname shared by several players can't stay as text, it becomes a chip + return matchesOf(player.name).length > 1 + ? rest + : `${rest}${player.name},\n`; + }); + if (matchesOf(player.name).length > 1) addPicked(player); + else setNickToId((prev) => new Map(prev).set(player.name, player.id)); + } + + function addPicked(player: PlayerOption) { + setPicked((prev) => + prev.some((p) => p.id === player.id) ? prev : [...prev, player], + ); + } + + function reset() { + setRaw(""); + setNickToId(new Map()); + setPicked([]); + setUnresolved([]); + } + + return { + raw, + setRaw, + picked, + setPicked, + unresolved, + setUnresolved, + count: items.length + picked.length, + matchesOf, + prepare, + applyFailed, + pickSuggestion, + addPicked, + reset, + }; +} + +/** + * Players typed as emails, IDs or picked nicknames. Players the server could not find + * are shown as rows below: fix the email, add the person as a guest, or drop them. + */ +export function PlayersField({ + field, + options, + multiline, +}: { + field: PlayersFieldState; + options: PlayerOption[]; + multiline?: boolean; +}) { + const [showSuggestions, setShowSuggestions] = useState(false); + const [guestBusy, setGuestBusy] = useState(null); + const wrapRef = useRef(null); + const inputRef = useRef(null); + const refresh = useTabletennisRefresh(); + const { showError } = useToast(); + + const { mutateAsync: createGuest } = $tabletennis.useMutation( + "post", + "/guests", + { onError: (e) => showError("Error", formatApiErrorMessage(e)) }, + ); + + useEffect(() => { + function handleOutsideClick(e: MouseEvent) { + if (!wrapRef.current?.contains(e.target as Node)) + setShowSuggestions(false); + } + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, []); + + const token = useMemo(() => { + const tokens = field.raw.split(/[\n,]+/); + return tokens[tokens.length - 1]?.trim() ?? ""; + }, [field.raw]); + + const suggestions = useMemo(() => { + if (!token) return []; + const lower = token.toLowerCase(); + const typed = typedTokens(field.raw); + return options + .filter( + (p) => + !typed.has(p.name.toLowerCase()) && !typed.has(p.id.toLowerCase()), + ) + .filter( + (p) => + p.name.toLowerCase().includes(lower) || + p.id.toLowerCase().includes(lower), + ) + .slice(0, 10); + }, [options, token, field.raw]); + + async function handleAddGuest(row: Unresolved) { + setGuestBusy(row.key); + try { + const guest = (await createGuest({ + params: { query: { nickname: guestNickname(row.text) } }, + })) as { innohassle_id: string; nickname: string; rating: number }; + field.addPicked({ + id: guest.innohassle_id, + name: guest.nickname, + rating: guest.rating, + guest: true, + }); + field.setUnresolved((prev) => prev.filter((u) => u.key !== row.key)); + refresh(); + } catch { + // toast shown by onError + } finally { + setGuestBusy(null); + } + } + + const inputProps = { + ref: inputRef, + value: field.raw, + onChange: (e: React.ChangeEvent) => + field.setRaw(e.target.value), + onFocus: () => setShowSuggestions(true), + placeholder: "Player emails or nicknames (comma or newline separated)", + }; + + return ( +
    +
    + {multiline ? ( +