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 && (
+
+ {reopening ? (
+
+ ) : (
+ <>
+
+ Reopen
+ >
+ )}
+
+ )}
+
+
+
+ {(["validation", "qualification"] as const).map((m) => (
+ setMode(m)}
+ >
+ {m === "validation" ? "Validation" : "Qualification"}
+
+ ))}
+
+
+ {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 (
+
+ );
+}
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 && (
+
setShowAddPlayers((v) => !v)}
+ >
+ Add players
+
+ )}
+ {isAdmin && canDelete && (
+
+ )}
+ {isAdmin && (
+
setShowEndConfirm(true)}
+ disabled={finishing}
+ >
+ {finishing ? (
+
+ ) : (
+ "End"
+ )}
+
+ )}
+
+
+
+ {(["validation", "qualification"] as const).map((s) => (
+ onStageChange(s)}
+ >
+ {s === "validation" ? "Validation" : "Qualification"}
+
+ ))}
+
+
+ {showAddPlayers && !groupsLocked && (
+
+
+
+ {
+ setShowAddPlayers(false);
+ addField.reset();
+ }}
+ >
+ Cancel
+
+ {
+ const body = addField.prepare();
+ if (body)
+ addPlayers({
+ params: { query: { tour_id: tour.id } },
+ body,
+ });
+ }}
+ disabled={addingPlayers || addField.count === 0}
+ >
+ {addingPlayers ? (
+
+ ) : (
+ "Add"
+ )}
+
+
+
+ )}
+
+ {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.
+
+ )}
+
+ setShowEndConfirm(false)}
+ >
+ Cancel
+
+ {
+ handleEndTournament();
+ setShowEndConfirm(false);
+ }}
+ disabled={finishing}
+ >
+ {finishing ? (
+
+ ) : (
+ "Confirm"
+ )}
+
+
+
+ >
+ );
+}
+
+/** 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 (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+
+ );
+}
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 (
+
+
+
+ );
+}
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 && (
+
+
- setConfirmOpen(true)}>
-
- Upgrade to Advanced
+ openFromMenu(setConfirmOpen)}
+ >
+
+ {targetStatus === "advanced"
+ ? "Upgrade to Advanced"
+ : "Downgrade to Beginner"}
- )}
- {targetStatus === "beginner" && (
- setConfirmOpen(true)}>
-
- Downgrade to Beginner
+
+
+ Set rating
- )}
- {targetStatus === null && (
-
-
- Already {player.status.toLowerCase()}
-
-
- )}
-
-
-
- Set starting rating
-
-
-
-
+ {player.placeholder && (
+
+ openFromMenu(setMergeOpen)}
+ >
+
+ Link to account
+
+
+ )}
+
+
+ )}
-
-
- 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 && (
+
+ )}
+
+
+
+
>
);
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 ? (
+
+ ) : (
+
+ )}
+ {showSuggestions && suggestions.length > 0 && (
+
+ {suggestions.map((p) => (
+ {
+ field.pickSuggestion(p);
+ setShowSuggestions(false);
+ inputRef.current?.focus();
+ }}
+ >
+
+ {p.name}
+
+ {p.guest && }
+
+ {p.rating}
+
+
+ ))}
+
+ )}
+
+
+ {field.picked.length > 0 && (
+
+ {field.picked.map((p) => (
+
+ {p.name}
+
+ {p.rating}
+
+ {p.guest && }
+
+ field.setPicked((prev) => prev.filter((x) => x.id !== p.id))
+ }
+ className="text-base-content/60 flex h-9 w-9 items-center justify-center"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {field.unresolved.length > 0 && (
+
+
+ Check {field.unresolved.length} player
+ {field.unresolved.length === 1 ? "" : "s"}: pick the right one, fix
+ the email, add as a guest, or remove.
+
+ {field.unresolved.map((row) => {
+ const nickname = guestNickname(row.text);
+ // an existing player with this name: offer them instead of a duplicate guest
+ const candidates =
+ row.code === "already_in_tournament"
+ ? []
+ : field
+ .matchesOf(nickname)
+ .filter((c) => !field.picked.some((p) => p.id === c.id));
+ return (
+
+
+
+ field.setUnresolved((prev) =>
+ prev.map((u) =>
+ u.key === row.key
+ ? { ...u, text: e.target.value }
+ : u,
+ ),
+ )
+ }
+ className="input input-bordered input-sm h-11 min-w-0 flex-1"
+ />
+ {row.code === "not_found" && candidates.length === 0 && (
+ handleAddGuest(row)}
+ className="flex h-11 shrink-0 items-center gap-1 rounded-xl border border-[#712BB2] px-3 text-xs font-medium text-[#712BB2] disabled:opacity-40"
+ >
+ {guestBusy === row.key ? (
+
+ ) : (
+ <>
+
+ Guest
+ >
+ )}
+
+ )}
+
+ field.setUnresolved((prev) =>
+ prev.filter((u) => u.key !== row.key),
+ )
+ }
+ className="text-base-content/60 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
+ >
+
+
+
+ {candidates.length > 0 && (
+
+
+ {row.code === "ambiguous" ? "Who is it?" : "Did you mean"}
+
+ {candidates.map((c) => (
+ {
+ field.addPicked(c);
+ field.setUnresolved((prev) =>
+ prev.filter((u) => u.key !== row.key),
+ );
+ }}
+ className="flex min-h-9 items-center gap-1.5 rounded-xl bg-[#712BB2] px-3 text-sm font-medium text-white"
+ >
+ {c.name}
+
+ {c.rating}
+
+ {c.guest && (
+
+ · guest
+
+ )}
+
+ ))}
+
+ )}
+
+ {row.reason}
+ {row.code === "not_found" &&
+ candidates.length === 0 &&
+ nickname.length >= 2 &&
+ ` · guest name: ${nickname}`}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export function GuestBadge({ className }: { className?: string }) {
+ return (
+
+ guest
+
+ );
+}
diff --git a/src/components/tabletennis/QualificationMatches.tsx b/src/components/tabletennis/QualificationMatches.tsx
index b101dfa9..ed5ea7b9 100644
--- a/src/components/tabletennis/QualificationMatches.tsx
+++ b/src/components/tabletennis/QualificationMatches.tsx
@@ -3,20 +3,20 @@ import { cn } from "@/lib/ui/cn";
import { $tabletennis, tabletennisTypes } from "@/api/tabletennis";
import { formatApiErrorMessage } from "@/api/helpers/create-query-client";
import { useToast } from "@/components/toast";
-import { BracketGraph, MatchCard } from "./BracketGraph";
+import { BracketGraph, CARD_H, CARD_W, MatchCard } from "./BracketGraph";
import { ScoreSheet } from "./ScoreSheet";
import {
type BracketMatch,
type GameData,
type Groups,
type Player,
- bracketSize,
+ type SeedEntry,
+ type Side,
buildSeeding,
groupGamesTotal,
ordinal,
resolveBracket,
roundLabel,
- seedOrder,
standingsToTop,
topsEqual,
visibleSections,
@@ -106,7 +106,25 @@ function SeedingPreview({
}) {
const { showError } = useToast();
const [busy, setBusy] = useState(false);
- const [manualOrder, setManualOrder] = useState(null);
+ // kept for this browser tab until qualification starts, so leaving the page loses nothing
+ const storageKey = `tabletennis-seeding-${tourId}`;
+ const [manualOrder, setManualOrderState] = useState(() => {
+ try {
+ const saved = sessionStorage.getItem(storageKey);
+ return saved ? (JSON.parse(saved) as string[]) : null;
+ } catch {
+ return null;
+ }
+ });
+ function setManualOrder(next: string[] | null) {
+ setManualOrderState(next);
+ try {
+ if (next) sessionStorage.setItem(storageKey, JSON.stringify(next));
+ else sessionStorage.removeItem(storageKey);
+ } catch {
+ // storage unavailable, the order lives until the page is left
+ }
+ }
const { mutateAsync: setSeeding } = $tabletennis.useMutation(
"post",
"/reg-tour/set-qual-seeding",
@@ -129,30 +147,42 @@ function SeedingPreview({
: computedOrder;
const edited = order.join() !== computedOrder.join();
- function moveSeed(id: string, toIndex: number) {
- const from = order.indexOf(id);
- if (from < 0 || from === toIndex) return;
- if (toIndex < 0 || toIndex >= order.length) return;
+ const [selected, setSelected] = useState(null);
+
+ function swap(a: string, b: string) {
const next = [...order];
- next.splice(from, 1);
- next.splice(toIndex, 0, id);
+ const i = next.indexOf(a);
+ const j = next.indexOf(b);
+ if (i < 0 || j < 0 || i === j) return;
+ [next[i], next[j]] = [next[j]!, next[i]!];
setManualOrder(next);
}
- const { drag, chipProps } = useGroupDrag((id, target) =>
- moveSeed(id, Number(target)),
- );
-
- // first-round opponent of every seed, to warn about same-group pairs after manual edits
- const opponentOf = useMemo(() => {
- const positions = seedOrder(bracketSize(order.length));
- const map = new Map();
- for (let i = 0; i < positions.length; i += 2) {
- map.set(positions[i]!, positions[i + 1]!);
- map.set(positions[i + 1]!, positions[i]!);
+ const { drag, chipProps, consumeClick } = useGroupDrag((id, target) => {
+ const other = order[Number(target)];
+ if (other) swap(id, other);
+ setSelected(null);
+ });
+
+ function handleTap(id: string) {
+ if (consumeClick()) return;
+ if (!selected || selected === id) {
+ setSelected(selected === id ? null : id);
+ return;
}
- return map;
- }, [order.length]);
+ swap(selected, id);
+ setSelected(null);
+ }
+
+ const preview = useMemo(() => resolveBracket(order, []), [order]);
+ const mainSection = visibleSections(preview, order.length)[0];
+ const selectedEntry = selected ? byId.get(selected) : undefined;
+ const sameGroupPairs = (mainSection?.matchIds[0] ?? []).filter((id) => {
+ const m = preview.matches.get(id)!;
+ if (m.side1.kind !== "player" || m.side2.kind !== "player") return false;
+ const g1 = byId.get(m.side1.id)?.group ?? null;
+ return g1 !== null && g1 === byId.get(m.side2.id)?.group;
+ }).length;
const totalGroupGames = Object.values(groups).reduce(
(s, m) => s + groupGamesTotal(m.length),
@@ -174,6 +204,7 @@ function SeedingPreview({
params: { query: { tour_id: tourId } },
body: order,
});
+ setManualOrder(null);
await onChanged();
} catch {
// toast shown by onError
@@ -191,7 +222,7 @@ function SeedingPreview({
so on. 1st seed meets the last one, players from the same group are
kept apart in the first round.
{isAdmin &&
- " Hold the handle and drag, or use the arrows, to change the order by hand."}
+ " Tap two players to swap them, or hold one and drag it onto another."}
@@ -216,86 +247,34 @@ function SeedingPreview({
)}
-
- {order.map((id, index) => {
- const s = byId.get(id)!;
- const seed = index + 1;
- const oppSeed = opponentOf.get(seed);
- const opp =
- oppSeed && oppSeed <= order.length
- ? byId.get(order[oppSeed - 1]!)
- : undefined;
- const sameGroup = !!opp && s.group !== null && opp.group === s.group;
- const isOver = drag?.over === String(index) && drag.id !== id;
- return (
-
- {isAdmin && (
-
-
-
- )}
-
- {seed}
-
-
-
- {getName(id)}
-
-
- {s.points} pts · sets {s.setsWon}:{s.setsLost}
- {sameGroup && (
-
- {" "}
- · same group as #{oppSeed} in round 1
-
- )}
-
-
- {s.group !== null && s.groupPlace !== null && (
-
- {s.group}
- {s.groupPlace}
-
- )}
- {isAdmin && (
-
- moveSeed(id, index - 1)}
- className="flex h-6 w-8 items-center justify-center disabled:opacity-20"
- >
-
-
- moveSeed(id, index + 1)}
- className="flex h-6 w-8 items-center justify-center disabled:opacity-20"
- >
-
-
-
- )}
-
- );
- })}
-
+ {mainSection && (
+
+
+ match.round === 0 ? (
+
+ ) : (
+
+ )
+ }
+ />
+
+ )}
{drag && (
-
+
{getName(drag.id)}
)}
+ {sameGroupPairs > 0 && (
+
+ {sameGroupPairs} first-round pair
+ {sameGroupPairs === 1 ? " is" : "s are"} from the same group
+ (highlighted).
+
+ )}
+
+ {isAdmin && selected && selectedEntry && (
+
+
+ {getName(selected)}
+
+ {" "}
+ · seed {order.indexOf(selected) + 1}
+ {selectedEntry.group !== null &&
+ ` · ${selectedEntry.group}${selectedEntry.groupPlace ?? ""}`}{" "}
+ · {selectedEntry.points} pts · sets {selectedEntry.setsWon}:
+ {selectedEntry.setsLost}
+
+
+ Tap another player to swap them
+
+
+ setSelected(null)}
+ className="h-10 shrink-0 rounded-xl px-3 text-sm text-[#712BB2]"
+ >
+ Cancel
+
+
+ )}
+
{isAdmin && edited && (
@@ -315,7 +328,10 @@ function SeedingPreview({
setManualOrder(null)}
+ onClick={() => {
+ setManualOrder(null);
+ setSelected(null);
+ }}
className="h-9 rounded-xl px-2 font-medium text-[#712BB2]"
>
Reset to results
@@ -687,6 +703,107 @@ function Bracket({
);
}
+function SeedCard({
+ match,
+ style,
+ order,
+ entries,
+ getName,
+ editable,
+ selected,
+ dragId,
+ dragOver,
+ onTap,
+ dragProps,
+}: {
+ match: BracketMatch;
+ style: React.CSSProperties;
+ order: string[];
+ entries: Map;
+ getName: (id: string) => string;
+ editable: boolean;
+ selected: string | null;
+ dragId: string | null;
+ dragOver: string | null;
+ onTap: (id: string) => void;
+ dragProps: ReturnType["chipProps"];
+}) {
+ const group = (side: Side) =>
+ side.kind === "player" ? (entries.get(side.id)?.group ?? null) : null;
+ const sameGroup =
+ group(match.side1) !== null && group(match.side1) === group(match.side2);
+
+ function renderSide(side: Side) {
+ if (side.kind !== "player") {
+ return (
+
+ bye
+
+ );
+ }
+ const index = order.indexOf(side.id);
+ const entry = entries.get(side.id);
+ const isSelected = selected === side.id;
+ const isOver =
+ dragOver === String(index) && dragId !== null && dragId !== side.id;
+ return (
+ onTap(side.id)}
+ {...(editable ? dragProps(side.id) : {})}
+ {...{ [DROP_ATTR]: String(index) }}
+ className={cn(
+ "flex h-1/2 w-full items-center gap-1.5 px-2 text-left transition-colors disabled:cursor-default",
+ editable &&
+ "touch-manipulation select-none [-webkit-touch-callout:none]",
+ isSelected && "bg-[#712BB2] text-white",
+ isOver && "bg-[#712BB2]/20",
+ dragId === side.id && "opacity-40",
+ editable && selected && !isSelected && "hover:bg-[#712BB2]/10",
+ )}
+ >
+
+ {index + 1}
+
+
+ {getName(side.id)}
+
+ {entry?.group !== null && entry?.groupPlace !== null && entry && (
+
+ {entry.group}
+ {entry.groupPlace}
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {renderSide(match.side1)}
+
+ {renderSide(match.side2)}
+
+ );
+}
+
function Notice({
children,
soft,
diff --git a/src/components/tabletennis/ScoreSheet.tsx b/src/components/tabletennis/ScoreSheet.tsx
index 1c36506f..78ff536a 100644
--- a/src/components/tabletennis/ScoreSheet.tsx
+++ b/src/components/tabletennis/ScoreSheet.tsx
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
import { cn } from "@/lib/ui/cn";
import { Modal } from "@/components/common/Modal.tsx";
+/** sets won by one player in a match, best of 7 at most (MAX_SCORE on the backend) */
+const MAX_SETS = 4;
+
function Stepper({
name,
value,
@@ -40,7 +43,7 @@ function Stepper({
= 9}
+ disabled={disabled || value >= MAX_SETS}
onClick={() => onChange(value + 1)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#712BB2] text-xl text-white disabled:opacity-30"
>
diff --git a/src/components/tabletennis/TableTennisMyMatches.tsx b/src/components/tabletennis/TableTennisMyMatches.tsx
index 069c2c7f..ccf605b7 100644
--- a/src/components/tabletennis/TableTennisMyMatches.tsx
+++ b/src/components/tabletennis/TableTennisMyMatches.tsx
@@ -10,6 +10,7 @@ import { Registration } from "./Registration";
import { Pager } from "./Pager";
import { pageSlice } from "./ranking";
import { formatDay, formatDayTime } from "./format";
+import { BACKGROUND_REFETCH_MS } from "./useTabletennisRefresh";
type SchemaPlayer = tabletennisTypes.SchemaPlayer;
@@ -21,11 +22,6 @@ type TourData = {
name: string;
date: string;
players: string[];
- games: {
- val_game_ids: string[] | null;
- cval_game_ids: string[] | null;
- total_count: number;
- };
};
type GamePlayer = {
@@ -47,12 +43,6 @@ type GameData = {
player2: GamePlayer;
};
-type GetGamesByIdResponse = {
- total_found: number;
- games: GameData[];
- missing_ids: string[];
-};
-
function gameTime(g: GameData) {
return g.finished_at ?? g.created_at ?? null;
}
@@ -102,6 +92,8 @@ function MatchesList({ playerId }: { playerId: string }) {
const { data: toursData, isPending: toursPending } = $tabletennis.useQuery(
"get",
"/get-tours",
+ undefined,
+ { refetchInterval: BACKGROUND_REFETCH_MS },
);
const tours = useMemo(
@@ -109,34 +101,19 @@ function MatchesList({ playerId }: { playerId: string }) {
[toursData],
);
- const allGameIds = useMemo(() => {
- const ids: string[] = [];
- for (const t of tours) {
- if (t.games?.val_game_ids) ids.push(...t.games.val_game_ids);
- if (t.games?.cval_game_ids) ids.push(...t.games.cval_game_ids);
- }
- return ids;
- }, [tours]);
-
- const { data: gamesByIdData, isPending: gamesPending } =
- $tabletennis.useQuery(
- "get",
- "/get-games-by-id",
- { params: { query: { ids: allGameIds } } },
- { enabled: allGameIds.length > 0 },
- );
+ const { data: myGamesData, isPending: gamesPending } = $tabletennis.useQuery(
+ "get",
+ "/my-games",
+ undefined,
+ { refetchInterval: BACKGROUND_REFETCH_MS },
+ );
// grouped by tournament id (two tournaments may share a name), newest first
const matchesByTournament = useMemo(() => {
- if (!gamesByIdData) return [];
- const games = (gamesByIdData as GetGamesByIdResponse).games ?? [];
- const mine = games.filter(
- (g) =>
- g.player1.innohassle_id === playerId ||
- g.player2.innohassle_id === playerId,
- );
+ if (!myGamesData) return [];
+ const games = (myGamesData as { games?: GameData[] }).games ?? [];
const byTour = new Map();
- for (const g of mine) {
+ for (const g of games) {
if (!byTour.has(g.tour_id)) byTour.set(g.tour_id, []);
byTour.get(g.tour_id)!.push(g);
}
@@ -154,9 +131,9 @@ function MatchesList({ playerId }: { playerId: string }) {
};
})
.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
- }, [gamesByIdData, playerId, tours]);
+ }, [myGamesData, tours]);
- if (toursPending || (allGameIds.length > 0 && gamesPending)) {
+ if (toursPending || gamesPending) {
return
;
}
diff --git a/src/components/tabletennis/TabletennisPage.tsx b/src/components/tabletennis/TabletennisPage.tsx
index f24fc3ed..eccc973d 100644
--- a/src/components/tabletennis/TabletennisPage.tsx
+++ b/src/components/tabletennis/TabletennisPage.tsx
@@ -7,6 +7,7 @@ import { SignInButton } from "@/components/common/SignInButton";
import { Registration } from "./Registration";
import { ScoreTable, type RatingPoint } from "./DrawTable";
import { rankByRating } from "./ranking";
+import { BACKGROUND_REFETCH_MS } from "./useTabletennisRefresh";
type SchemaPlayer = tabletennisTypes.SchemaPlayer;
@@ -14,6 +15,8 @@ export function TabletennisPage() {
const { data, isPending, isError, error, refetch } = $tabletennis.useQuery(
"get",
"/get-player",
+ undefined,
+ { refetchInterval: BACKGROUND_REFETCH_MS },
);
if (isPending) return
;
@@ -134,14 +137,18 @@ function ProfileInfo({
}
function usePlace(player: SchemaPlayer): number | null {
- const { data } = $tabletennis.useQuery("get", "/players");
+ const { data } = $tabletennis.useQuery("get", "/players", undefined, {
+ refetchInterval: BACKGROUND_REFETCH_MS,
+ });
if (!data) return null;
const { players } = data as unknown as {
players: (SchemaPlayer & { is_active: boolean })[];
};
// same place as in the players top: counted among active players (plus yourself)
const pool = players.filter(
- (p) => p.is_active || p.innohassle_id === player.innohassle_id,
+ (p) =>
+ !p.placeholder &&
+ (p.is_active || p.innohassle_id === player.innohassle_id),
);
const me = rankByRating(pool).find(
(r) => r.player.innohassle_id === player.innohassle_id,
diff --git a/src/components/tabletennis/TabletennisPlayersTop.tsx b/src/components/tabletennis/TabletennisPlayersTop.tsx
index aa2e70b8..a8e20b56 100644
--- a/src/components/tabletennis/TabletennisPlayersTop.tsx
+++ b/src/components/tabletennis/TabletennisPlayersTop.tsx
@@ -6,17 +6,23 @@ import {
isApiHttpError,
} from "@/api/helpers/create-query-client";
import { PlayerActionMenu } from "./PlayerActionMenu.tsx";
+import { GuestBadge } from "./PlayersField";
+import { BACKGROUND_REFETCH_MS } from "./useTabletennisRefresh";
import { Pager } from "./Pager";
import { pageSlice, rankByRating } from "./ranking";
+const PLAYERS_PAGE_SIZE = 25;
+
type PlayerEntry = {
place: number;
name: string;
rating: number;
status: string;
visible: boolean;
+ isAdmin: boolean;
+ guest: boolean;
innohassle_id: string;
- raw: SchemaPlayer & { is_active: boolean };
+ raw: ListedPlayer;
};
type SortKey = "name" | "rating";
@@ -28,11 +34,14 @@ type Filters = {
ratingMax: number;
nameSearch: string;
showHidden: boolean;
+ adminsOnly: boolean;
+ guestsOnly: boolean;
sortKey: SortKey;
sortDir: SortDir;
};
type SchemaPlayer = tabletennisTypes.SchemaPlayer;
+type ListedPlayer = SchemaPlayer & { is_active: boolean; is_admin?: boolean };
function SortArrow({
sortKey,
@@ -54,20 +63,21 @@ const INITIAL_FILTERS: Filters = {
ratingMax: 9999,
nameSearch: "",
showHidden: false,
+ adminsOnly: false,
+ guestsOnly: false,
sortKey: "rating",
sortDir: "desc",
};
-function toPlayerEntry(
- p: SchemaPlayer & { is_active: boolean },
- place: number,
-): PlayerEntry {
+function toPlayerEntry(p: ListedPlayer, place: number): PlayerEntry {
return {
place,
name: p.nickname,
rating: p.rating,
status: p.status ?? "",
visible: p.is_active,
+ isAdmin: p.is_admin ?? false,
+ guest: p.placeholder,
innohassle_id: p.innohassle_id,
raw: p,
};
@@ -77,6 +87,8 @@ export function TabletennisPlayersTop() {
const { data, isPending, isError, error } = $tabletennis.useQuery(
"get",
"/players",
+ undefined,
+ { refetchInterval: BACKGROUND_REFETCH_MS },
);
const { data: isAdminData } = $tabletennis.useQuery("get", "/isadmin");
@@ -104,7 +116,7 @@ export function TabletennisPlayersTop() {
if (!data) return [];
const raw = data as unknown as {
total: number;
- players: (SchemaPlayer & { is_active: boolean })[];
+ players: ListedPlayer[];
};
const pool = raw.players
.filter((p) => debounced.showHidden || p.is_active)
@@ -113,10 +125,16 @@ export function TabletennisPlayersTop() {
debounced.status === "all" ||
(p.status ?? "").toLowerCase() === debounced.status,
);
- return rankByRating(pool).map(({ player, place }) =>
- toPlayerEntry(player, place),
+ // guests have no place in the top; only admins see them, to link them to accounts
+ const ranked = rankByRating(pool.filter((p) => !p.placeholder)).map(
+ ({ player, place }) => toPlayerEntry(player, place),
);
- }, [data, debounced.showHidden, debounced.status]);
+ if (!isAdmin) return ranked;
+ const guests = pool
+ .filter((p) => p.placeholder)
+ .map((p) => toPlayerEntry(p, 0));
+ return [...ranked, ...guests];
+ }, [data, debounced.showHidden, debounced.status, isAdmin]);
const results = useMemo(() => {
const filtered = entries
@@ -126,7 +144,9 @@ export function TabletennisPlayersTop() {
)
.filter((p) =>
p.name.toLowerCase().includes(debounced.nameSearch.toLowerCase()),
- );
+ )
+ .filter((p) => !debounced.adminsOnly || p.isAdmin)
+ .filter((p) => !debounced.guestsOnly || p.guest);
const sorted = [...filtered].sort((a, b) => {
const mul = debounced.sortDir === "asc" ? 1 : -1;
@@ -138,7 +158,7 @@ export function TabletennisPlayersTop() {
return sorted;
}, [entries, debounced]);
- const visibleResults = pageSlice(results, page);
+ const visibleResults = pageSlice(results, page, PLAYERS_PAGE_SIZE);
function toggleSort(key: SortKey) {
setFilters((prev) => ({
@@ -246,6 +266,33 @@ export function TabletennisPlayersTop() {
/>
Show hidden
+
+
+ setFilters((prev) => ({ ...prev, adminsOnly: e.target.checked }))
+ }
+ />
+ Admins only
+
+ {isAdmin && (
+
+
+ setFilters((prev) => ({
+ ...prev,
+ guestsOnly: e.target.checked,
+ }))
+ }
+ />
+ Guests only
+
+ )}
{/* Results count */}
@@ -253,7 +300,12 @@ export function TabletennisPlayersTop() {
{results.length} player{results.length === 1 ? "" : "s"}
-
+
{/* Table */}
@@ -294,20 +346,30 @@ export function TabletennisPlayersTop() {
key={p.innohassle_id}
className="border-base-300 border-b last:border-0"
>
-
{p.place}
+
+ {p.guest ? "—" : p.place}
+
{p.name}
{p.rating}
-
+
+ {p.status}
+
+ {p.isAdmin && (
+
+ admin
+
)}
- >
- {p.status}
-
+ {p.guest && }
+
Players
My matches
Tournaments
- Events
{/* {clubsUser?.role === "admin" && (
Admin
)} */}
@@ -20,7 +19,7 @@ function TabLink(props: ValidateLinkOptions) {
return (
diff --git a/src/components/tabletennis/TabletennisTournaments.tsx b/src/components/tabletennis/TabletennisTournaments.tsx
index 8d3f739a..1972c400 100644
--- a/src/components/tabletennis/TabletennisTournaments.tsx
+++ b/src/components/tabletennis/TabletennisTournaments.tsx
@@ -1,65 +1,30 @@
-import { useState, useMemo, useRef, useCallback, useEffect } from "react";
+import { useState, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
-import { cn } from "@/lib/ui/cn";
+import { useNavigate } from "@tanstack/react-router";
import { $tabletennis, tabletennisTypes } from "@/api/tabletennis";
+import { ArchivedTournament } from "./ArchivedTournament";
+import { CreateTournamentForm } from "./CreateTournamentForm";
import {
- formatApiErrorMessage,
- isApiHttpError,
-} 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 { resolveBracket, standingsToTop, topsEqual } from "./bracket-utils";
+ LiveTournament,
+ type TourData,
+ type TournamentStage,
+} from "./LiveTournament";
+import {
+ LIVE_REFETCH_MS,
+ useTabletennisRefresh,
+} from "./useTabletennisRefresh";
+import type { PlayerOption } from "./PlayersField";
import { Pager } from "./Pager";
import { pageSlice } from "./ranking";
import { formatDay } from "./format";
+export type { TournamentStage };
+
/** places shown for a completed tournament before "Show all" */
const TOP_PLACES_PREVIEW = 3;
type SchemaPlayer = tabletennisTypes.SchemaPlayer;
-type TourData = {
- id: string;
- name: string;
- date: string;
- players: string[];
- 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[];
-};
-
-type GamePlayer = {
- innohassle_id: string;
- nickname: string;
- rating: number;
- registered: boolean;
- score: number;
-};
-
-type GameData = {
- game_id: string;
- tour_id: string;
- tournament_name: string;
- finished: boolean;
- player1: GamePlayer;
- player2: GamePlayer;
-};
-
-type GetGamesByIdResponse = {
- total_found: number;
- games: GameData[];
- missing_ids: string[];
-};
-
function getPlacementData(tour: TourData):
| {
playerId: string;
@@ -84,69 +49,34 @@ function getPlacementData(tour: TourData):
}));
}
-/** players already written in the input (every token except the one being typed) */
-function typedTokens(raw: string): Set {
- const tokens = raw.split(/[\n,]+/);
- return new Set(
- tokens
- .slice(0, -1)
- .map((t) => t.trim().toLowerCase())
- .filter(Boolean),
- );
+function stageLabel(tour: TourData): string {
+ if ((tour.qual_seeding ?? []).length > 0) return "Qualification";
+ if (tour.groups_locked) return "Group stage";
+ return "Forming groups";
}
-function parsePlayersInput(raw: string): {
- player_ids: string[];
- emails: string[];
-} {
- const items = raw
- .split(/[\n,]+/)
- .map((s) => s.trim())
- .filter(Boolean);
- const player_ids: string[] = [];
- const emails: string[] = [];
- for (const item of items) {
- if (item.includes("@")) {
- emails.push(item);
- } else {
- player_ids.push(item);
- }
- }
- return { player_ids, emails };
-}
-
-export function TabletennisTournaments() {
- const [mode, setMode] = useState<"validation" | "qualification">(
- "validation",
- );
+export function TabletennisTournaments({
+ tourId,
+ stage,
+}: {
+ tourId?: string;
+ stage: TournamentStage;
+}) {
const [showCreateForm, setShowCreateForm] = useState(false);
- const [showAddPlayers, setShowAddPlayers] = useState(false);
- const [addPlayersInput, setAddPlayersInput] = useState("");
- const [addPlayersNickToId, setAddPlayersNickToId] = useState<
- Map
- >(new Map());
- const [name, setName] = useState("");
- const [playersInput, setPlayersInput] = useState("");
- const [createNickToId, setCreateNickToId] = useState>(
- new Map(),
- );
- const [showPlayerSuggestions, setShowPlayerSuggestions] = useState(false);
- const [showCreateSuggestions, setShowCreateSuggestions] = useState(false);
- const [showEndConfirm, setShowEndConfirm] = useState(false);
const [completedPage, setCompletedPage] = useState(0);
const [expandedTours, setExpandedTours] = useState>(new Set());
- const addInputRef = useRef(null);
- const createInputRef = useRef(null);
- const { showError } = useToast();
-
- const {
- data: toursData,
- isPending: toursPending,
- refetch: refetchTours,
- } = $tabletennis.useQuery("get", "/active-tours");
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const refresh = useTabletennisRefresh();
- const { data: allToursData, refetch: refetchAllTours } =
- $tabletennis.useQuery("get", "/get-tours");
+ // one list for active and completed tournaments, so a tournament is always in exactly one of them
+ const toursQuery = $tabletennis.queryOptions("get", "/get-tours");
+ const { data: toursData, isPending: toursPending } = $tabletennis.useQuery(
+ "get",
+ "/get-tours",
+ undefined,
+ { refetchInterval: LIVE_REFETCH_MS },
+ );
const { data: playersData } = $tabletennis.useQuery("get", "/players");
@@ -154,647 +84,186 @@ export function TabletennisTournaments() {
const isAdmin =
(isAdminData as { is_admin?: boolean } | undefined)?.is_admin ?? false;
- const { mutate: createTour, isPending: creating } = $tabletennis.useMutation(
- "post",
- "/reg-tour",
- {
- onSuccess: () => {
- refetchTours();
- setShowCreateForm(false);
- setName("");
- setPlayersInput("");
- setCreateNickToId(new Map());
- },
- onError: (error) => {
- if (isApiHttpError(error) && error.httpCode === 403) {
- showError(
- "Access denied",
- "Only administrators can create tournaments",
- );
- } else {
- showError("Error", formatApiErrorMessage(error));
- }
- },
- },
- );
-
- const { mutate: finishTour, isPending: finishing } = $tabletennis.useMutation(
- "post",
- "/finish-tour",
- {
- onSuccess: () => {
- refetchTours();
- setMode("validation");
- },
- onError: (error) => showError("Error", formatApiErrorMessage(error)),
- },
- );
-
- const { mutate: addPlayers, isPending: addingPlayers } =
- $tabletennis.useMutation("post", "/reg-tour/add-player", {
- onSuccess: () => {
- refetchTours();
- setShowAddPlayers(false);
- setAddPlayersInput("");
- setAddPlayersNickToId(new Map());
- },
- onError: (error) => showError("Error", formatApiErrorMessage(error)),
- });
-
- const activeTour = useMemo(() => {
- if (!toursData) return null;
- const tours = toursData as unknown as TourData[];
- return tours.length > 0 ? tours[0]! : null;
- }, [toursData]);
-
- const completedTours = useMemo(() => {
- if (!allToursData) return [];
- const activeIds = new Set(
- ((toursData as unknown as TourData[]) ?? []).map((t) => t.id),
- );
- const all = allToursData as unknown as TourData[];
- return all.filter((t) => !activeIds.has(t.id));
- }, [allToursData, toursData]);
-
- const gameIds = useMemo(() => {
- if (!activeTour) return [];
- return [
- ...(activeTour.games.val_game_ids ?? []),
- ...(activeTour.games.cval_game_ids ?? []),
- ];
- }, [activeTour]);
-
- const { data: gamesByIdData, refetch: refetchGames } = $tabletennis.useQuery(
- "get",
- "/get-games-by-id",
- { params: { query: { ids: gameIds } } },
- { enabled: gameIds.length > 0 },
- );
-
- const playersList = useMemo(() => {
- if (!playersData || !activeTour) return [];
- const raw = playersData as unknown as {
- total: number;
- players: (SchemaPlayer & { is_active: boolean })[];
+ const { activeTours, completedTours } = useMemo(() => {
+ const all = (toursData as unknown as TourData[] | undefined) ?? [];
+ return {
+ activeTours: all.filter((t) => t.active),
+ completedTours: all.filter((t) => !t.active),
};
- const list = Array.isArray(raw) ? raw : raw.players;
- const tourPlayerIds = new Set(activeTour.players);
- return (list ?? [])
- .filter(
- (p) =>
- tourPlayerIds.has(p.id ?? "") || tourPlayerIds.has(p.innohassle_id),
- )
- .map((p) => ({
- name: p.nickname,
- id: p.innohassle_id,
- rating: p.rating,
- }));
- }, [playersData, activeTour]);
-
- const playerNameMap = useMemo(() => {
- const map = new Map();
- if (!playersData) return map;
- const raw = playersData as unknown as {
- total: number;
- players: (SchemaPlayer & { is_active: boolean })[];
- };
- const list = Array.isArray(raw) ? raw : raw.players;
- for (const p of list ?? []) {
- map.set(p.innohassle_id, p.nickname);
- }
- return map;
- }, [playersData]);
+ }, [toursData]);
- const allPlayers = useMemo(() => {
+ const allPlayers = useMemo(() => {
if (!playersData) return [];
const raw = playersData as unknown as {
- total: number;
players: (SchemaPlayer & { is_active: boolean })[];
};
- const list = Array.isArray(raw) ? raw : raw.players;
- return (list ?? []).map((p) => ({
+ return (raw.players ?? []).map((p) => ({
name: p.nickname,
id: p.innohassle_id,
+ rating: p.rating,
+ guest: p.placeholder,
}));
}, [playersData]);
- const addablePlayers = useMemo(() => {
- if (!playersData || !activeTour) return [];
- const raw = playersData as unknown as {
- total: number;
- players: (SchemaPlayer & { is_active: boolean })[];
- };
- const list = Array.isArray(raw) ? raw : raw.players;
- const tourPlayerIds = new Set(activeTour.players);
- return (list ?? []).filter(
- (p) =>
- !tourPlayerIds.has(p.id ?? "") && !tourPlayerIds.has(p.innohassle_id),
- );
- }, [playersData, activeTour]);
-
- const addInputToken = useMemo(() => {
- const tokens = addPlayersInput.split(/[\n,]+/);
- return tokens[tokens.length - 1]?.trim() ?? "";
- }, [addPlayersInput]);
-
- const addSuggestions = useMemo(() => {
- if (!addInputToken || addInputToken.length < 1) return [];
- const lower = addInputToken.toLowerCase();
- const typed = typedTokens(addPlayersInput);
- return addablePlayers
- .filter(
- (p) =>
- !typed.has(p.nickname.toLowerCase()) &&
- !typed.has(p.innohassle_id.toLowerCase()),
- )
- .filter(
- (p) =>
- p.nickname.toLowerCase().includes(lower) ||
- p.innohassle_id.toLowerCase().includes(lower),
- )
- .slice(0, 10);
- }, [addablePlayers, addInputToken, addPlayersInput]);
-
- const createInputToken = useMemo(() => {
- const tokens = playersInput.split(/[\n,]+/);
- return tokens[tokens.length - 1]?.trim() ?? "";
- }, [playersInput]);
-
- const createSuggestions = useMemo(() => {
- if (!createInputToken || createInputToken.length < 1) return [];
- const lower = createInputToken.toLowerCase();
- const typed = typedTokens(playersInput);
- return allPlayers
- .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);
- }, [allPlayers, createInputToken, playersInput]);
-
- const allGames = useMemo(() => {
- if (!gamesByIdData) return [];
- const response = gamesByIdData as GetGamesByIdResponse;
- return response.games ?? [];
- }, [gamesByIdData]);
-
- const validationGames = useMemo(() => {
- if (!activeTour?.games?.val_game_ids) return [];
- const ids = new Set(activeTour.games.val_game_ids);
- return allGames.filter((g) => ids.has(g.game_id));
- }, [allGames, activeTour]);
-
- const qualificationGames = useMemo(() => {
- if (!activeTour?.games?.cval_game_ids) return [];
- const ids = new Set(activeTour.games.cval_game_ids);
- return allGames.filter((g) => ids.has(g.game_id));
- }, [allGames, activeTour]);
-
- const queryClient = useQueryClient();
- const refreshTournament = useCallback(async () => {
- await Promise.all([
- refetchTours(),
- queryClient.invalidateQueries({
- queryKey: ["tabletennis", "get", "/get-games-by-id"],
- }),
- ]);
- refetchAllTours();
- }, [refetchTours, refetchAllTours, queryClient]);
-
- const groupsLocked = activeTour?.groups_locked ?? false;
-
- const { mutateAsync: changeQualTop } = $tabletennis.useMutation(
- "post",
- "/reg-tour/change-qual-top",
- { onError: (error) => showError("Error", formatApiErrorMessage(error)) },
+ const playerNameMap = useMemo(
+ () => new Map(allPlayers.map((p) => [p.id, p.name])),
+ [allPlayers],
);
- const qualProgress = useMemo(() => {
- const seeding = activeTour?.qual_seeding ?? [];
- 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 };
- }, [activeTour, qualificationGames]);
-
- const handleAddOutsideClick = useCallback((e: MouseEvent) => {
- if (
- addInputRef.current &&
- !addInputRef.current.parentElement?.contains(e.target as Node)
- ) {
- setShowPlayerSuggestions(false);
- }
- }, []);
-
- const handleCreateOutsideClick = useCallback((e: MouseEvent) => {
- if (
- createInputRef.current &&
- !createInputRef.current.parentElement?.contains(e.target as Node)
- ) {
- setShowCreateSuggestions(false);
- }
- }, []);
-
- useEffect(() => {
- document.addEventListener("mousedown", handleAddOutsideClick);
- document.addEventListener("mousedown", handleCreateOutsideClick);
- return () => {
- document.removeEventListener("mousedown", handleAddOutsideClick);
- document.removeEventListener("mousedown", handleCreateOutsideClick);
- };
- }, [handleAddOutsideClick, handleCreateOutsideClick]);
+ /** moves the tournament between active and completed right away, the refetch confirms it */
+ function setTourActive(id: string, active: boolean) {
+ queryClient.setQueryData(
+ toursQuery.queryKey,
+ (prev) =>
+ (prev as unknown as TourData[] | undefined)?.map((t) =>
+ t.id === id ? { ...t, active } : t,
+ ) as typeof prev,
+ );
+ return refresh();
+ }
- useEffect(() => {
- if (mode === "qualification") {
- refetchTours();
- refetchGames();
- }
- }, [mode, refetchTours, refetchGames]);
+ function removeTour(id: string) {
+ openTour(undefined);
+ queryClient.setQueryData(
+ toursQuery.queryKey,
+ (prev) =>
+ (prev as unknown as TourData[] | undefined)?.filter(
+ (t) => t.id !== id,
+ ) as typeof prev,
+ );
+ refresh();
+ }
- function handleCreate(e: React.FormEvent) {
- e.preventDefault();
- const { player_ids, emails } = parsePlayersInput(playersInput);
- const resolvedIds = player_ids.map((id) => createNickToId.get(id) ?? id);
- if (!name.trim() || resolvedIds.length + emails.length < 2) return;
- createTour({
- params: { query: { name: name.trim() } },
- body: { player_ids: resolvedIds, emails },
+ function openTour(id: string | undefined, nextStage?: TournamentStage) {
+ navigate({
+ to: "/tabletennis/tournaments",
+ search: {
+ tour: id,
+ stage: nextStage === "qualification" ? nextStage : undefined,
+ },
});
+ window.scrollTo({ top: 0 });
}
- async function handleEndTournament() {
- if (!activeTour) return;
- // 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, activeTour.qual_top ?? {})) {
- try {
- await changeQualTop({
- params: { query: { tour_id: activeTour.id } },
- body: top,
- });
- } catch {
- return; // toast shown by onError, keep the tournament open
- }
- }
- }
- finishTour({ params: { query: { tour_id: activeTour.id } } });
+ if (toursPending) {
+ return
;
}
- function handleAddPlayers() {
- if (!activeTour) return;
- const { player_ids, emails } = parsePlayersInput(addPlayersInput);
- const resolvedIds = player_ids.map(
- (id) => addPlayersNickToId.get(id) ?? id,
+ if (tourId) {
+ const live = activeTours.find((t) => t.id === tourId);
+ if (live) {
+ return (
+ openTour(tourId, s)}
+ onBack={() => openTour(undefined)}
+ onFinished={() => {
+ openTour(undefined);
+ setTourActive(live.id, false);
+ }}
+ onDeleted={() => removeTour(live.id)}
+ onChanged={refresh}
+ />
+ );
+ }
+ const archived = completedTours.find((t) => t.id === tourId);
+ if (archived) {
+ return (
+ openTour(undefined)}
+ onReopened={() => setTourActive(archived.id, true)}
+ onDeleted={() => removeTour(archived.id)}
+ />
+ );
+ }
+ return (
+
+
Tournament not found.
+
openTour(undefined)}
+ className="h-11 rounded-xl border border-[#712BB2] px-5 text-sm font-medium text-[#712BB2]"
+ >
+ All tournaments
+
+
);
- if (resolvedIds.length + emails.length === 0) return;
- addPlayers({
- params: { query: { tour_id: activeTour.id } },
- body: { player_ids: resolvedIds, emails },
- });
}
- if (toursPending) return
;
-
- if (activeTour) {
+ if (showCreateForm && isAdmin) {
return (
- <>
-
-
-
setMode("validation")}
- >
- Validation
-
+
{
+ setShowCreateForm(false);
+ await refresh();
+ openTour(id);
+ }}
+ onCancel={() => setShowCreateForm(false)}
+ />
+ );
+ }
+
+ return (
+ <>
+
+
+
Active tournaments
+ {isAdmin && (
setMode("qualification")}
+ className="flex h-11 shrink-0 items-center gap-1 rounded-xl bg-[#712BB2] px-4 text-sm font-medium text-white"
+ onClick={() => setShowCreateForm(true)}
>
- Qualification
+
+ Create
-
-
- {activeTour.name}
-
- {isAdmin && !groupsLocked && (
- setShowAddPlayers(true)}
- >
- Add players
-
- )}
- {isAdmin && (
- setShowEndConfirm(true)}
- disabled={finishing}
- >
- {finishing ? (
-
- ) : (
- "End tournament"
- )}
-
- )}
-
-
-
- {showAddPlayers && !groupsLocked && (
-
-
-
setAddPlayersInput(e.target.value)}
- onFocus={() => setShowPlayerSuggestions(true)}
- placeholder="Player emails or IDs (comma or newline separated)"
- className="input input-bordered input-sm w-full"
- />
- {showPlayerSuggestions && addSuggestions.length > 0 && (
-
- {addSuggestions.map((p) => (
- {
- const tokens = addPlayersInput.split(/[\n,]+/);
- const lastToken = tokens[tokens.length - 1] ?? "";
- const prefix = addPlayersInput.slice(
- 0,
- addPlayersInput.length - lastToken.length,
- );
- setAddPlayersInput(prefix + p.nickname + ",\n");
- setAddPlayersNickToId((prev) =>
- new Map(prev).set(p.nickname, p.innohassle_id),
- );
- setShowPlayerSuggestions(false);
- addInputRef.current?.focus();
- }}
- >
- {p.nickname}
-
- {p.innohassle_id}
-
-
- ))}
-
- )}
-
-
- {addingPlayers ? (
-
- ) : (
- "Add"
- )}
-
-
{
- setShowAddPlayers(false);
- setAddPlayersInput("");
- setAddPlayersNickToId(new Map());
- }}
- >
- Cancel
-
-
- )}
-
- {mode === "validation" && (
-
0}
- validationGames={validationGames}
- isAdmin={isAdmin}
- onChanged={refreshTournament}
- />
- )}
- {mode === "qualification" && (
-
)}
-
-
- Are you sure you want to finish {activeTour.name} ?
- This action cannot be undone.
-
- {!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{" "}
- {activeTour.qual_seeding?.length ?? 0}) will be saved, and no
- bonuses are granted.
+ {activeTours.length === 0 ? (
+
+
+
+ {isAdmin
+ ? "No active tournaments. Create one to start managing groups and the qualification bracket."
+ : "No active tournament at the moment."}
- )}
-
- setShowEndConfirm(false)}
- >
- Cancel
-
- {
- handleEndTournament();
- setShowEndConfirm(false);
- }}
- disabled={finishing}
- >
- {finishing ? (
-
- ) : (
- "Confirm"
- )}
-
-
- >
- );
- }
-
- if (showCreateForm && isAdmin) {
- return (
-
)}
@@ -387,22 +525,40 @@ function GroupSetup({
)}
-
-
run("save")}
- className="border-base-content/20 h-12 rounded-xl border px-6 text-sm font-medium disabled:opacity-40"
- >
- Save draft
-
+
+
+ {saving ? (
+ <>
+
+ Saving…
+ >
+ ) : saveFailed ? (
+
+ Not saved
+
+ Retry
+
+
+ ) : dirty ? (
+ "Unsaved changes"
+ ) : (
+ <>
+
+ Saved, other admins see these groups
+ >
+ )}
+
run("lock")}
+ disabled={busy !== null || !canLock}
+ onClick={lock}
className="flex h-12 items-center justify-center gap-2 rounded-xl bg-[#712BB2] px-6 text-sm font-medium text-white disabled:opacity-40"
>
- {busy ? (
+ {busy === "lock" ? (
) : (
<>
@@ -427,6 +583,7 @@ function GroupStage({
isAdmin,
getName,
onChanged,
+ onGoToQualification,
}: {
players: Player[];
tourId: string;
@@ -436,6 +593,7 @@ function GroupStage({
isAdmin: boolean;
getName: (id: string) => string;
onChanged: () => Promise;
+ onGoToQualification?: () => void;
}) {
const { showError, showConfirm } = useToast();
const [scoring, setScoring] = useState(null);
@@ -479,6 +637,10 @@ function GroupStage({
const finishedCount = validationGames.filter((g) => g.finished).length;
const leftCount = Math.max(0, totalGames - finishedCount);
const canPlay = isAdmin && !qualStarted;
+ const showNextStage =
+ !!onGoToQualification &&
+ (qualStarted ||
+ (leftCount === 0 && validationGames.every((g) => g.finished)));
const suggestions = useMemo(
() =>
canPlay
@@ -558,7 +720,12 @@ function GroupStage({
}
return (
-
+
Group stage
@@ -673,6 +840,20 @@ function GroupStage({
: undefined
}
/>
+
+ {showNextStage && (
+ {
+ onGoToQualification?.();
+ window.scrollTo({ top: 0 });
+ }}
+ className="fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-10 flex h-14 items-center gap-2 rounded-2xl bg-[#712BB2] px-5 text-sm font-semibold text-white shadow-[0_8px_24px_rgba(113,43,178,0.45)]"
+ >
+ {qualStarted ? "Open bracket" : "Qualification"}
+
+
+ )}
);
}
@@ -715,10 +896,13 @@ function GroupCard({
() => new Map(members.map((id) => [id, getName(id)])),
[members, getName],
);
- const standings = useMemo(
- () => groupStandings(name, members, groupGames, names),
- [name, members, groupGames, names],
- );
+ // rows keep the order the group was formed in (by rating), only the place column changes
+ const standings = useMemo(() => {
+ const byId = new Map(
+ groupStandings(name, members, groupGames, names).map((s) => [s.id, s]),
+ );
+ return members.map((id) => byId.get(id)!);
+ }, [name, members, groupGames, names]);
const results = new Map
();
const busyPlayers = new Set();
@@ -779,7 +963,7 @@ function GroupCard({
- #
+ №
Player
@@ -790,21 +974,24 @@ function GroupCard({
Sets
- {standings.map((s) => (
+
+ Pl
+
+ {standings.map((s, i) => (
- {s.place}
+ {i + 1}
))}
- {standings.map((row) => (
+ {standings.map((row, rowIndex) => (
-
- {row.place}
+
+ {rowIndex + 1}
{getName(row.id)}
@@ -815,6 +1002,9 @@ function GroupCard({
{row.setsWon}:{row.setsLost}
+
+ {row.played > 0 ? row.place : "–"}
+
{standings.map((col) => {
if (col.id === row.id) {
return (
@@ -1060,6 +1250,8 @@ function GroupShell({
function PlayerChip({
label,
+ rating,
+ guest,
active,
disabled,
hint,
@@ -1069,6 +1261,8 @@ function PlayerChip({
onContextMenu,
}: {
label: string;
+ rating?: number;
+ guest?: boolean;
active: boolean;
disabled?: boolean;
hint?: string;
@@ -1098,6 +1292,17 @@ function PlayerChip({
)}
>
{label}
+ {rating !== undefined && (
+
+ {rating}
+
+ )}
+ {guest && · guest }
{hint && · {hint} }
);
diff --git a/src/components/tabletennis/bracket-utils.ts b/src/components/tabletennis/bracket-utils.ts
index 674d887e..6f598373 100644
--- a/src/components/tabletennis/bracket-utils.ts
+++ b/src/components/tabletennis/bracket-utils.ts
@@ -1,4 +1,9 @@
-export type Player = { name: string; id: string; rating?: number };
+export type Player = {
+ name: string;
+ id: string;
+ rating?: number;
+ guest?: boolean;
+};
export type GameData = {
game_id: string;
diff --git a/src/components/tabletennis/useTabletennisRefresh.ts b/src/components/tabletennis/useTabletennisRefresh.ts
new file mode 100644
index 00000000..71ca3f2a
--- /dev/null
+++ b/src/components/tabletennis/useTabletennisRefresh.ts
@@ -0,0 +1,19 @@
+import { useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+
+/** an open tournament and the tournament list: admins see each other's changes */
+export const LIVE_REFETCH_MS = 5000;
+/** players top, profile and own matches */
+export const BACKGROUND_REFETCH_MS = 15000;
+
+/**
+ * Refetches every table tennis query. Any action changes several screens at once
+ * (a game changes the bracket, ratings, the top and profiles), so all of them are reloaded.
+ */
+export function useTabletennisRefresh() {
+ const queryClient = useQueryClient();
+ return useCallback(
+ () => queryClient.invalidateQueries({ queryKey: ["tabletennis"] }),
+ [queryClient],
+ );
+}