aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-09-17 19:14:32 +0900
committernsfisis <nsfisis@gmail.com>2025-09-17 19:23:56 +0900
commitfaff26b13da82747fb0efdb6bf1312a9b14d3916 (patch)
treeeb510dc3f92bdb916ff5bdc1191763ff48cf4332
parent4615ca9b8b1989d315ae2322556697b97161b97b (diff)
downloadiosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.tar.gz
iosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.tar.zst
iosdc-japan-2025-albatross-faff26b13da82747fb0efdb6bf1312a9b14d3916.zip
feat(backend,frontend): implement tournament page
-rw-r--r--backend/api/generated.go220
-rw-r--r--backend/api/handler.go94
-rw-r--r--backend/api/handler_wrapper.go12
-rw-r--r--frontend/app/api/client.ts17
-rw-r--r--frontend/app/api/schema.d.ts65
-rw-r--r--frontend/app/components/Gaming/Score.tsx4
-rw-r--r--frontend/app/routes/tournament.tsx441
-rw-r--r--openapi/api-server.yaml79
8 files changed, 905 insertions, 27 deletions
diff --git a/backend/api/generated.go b/backend/api/generated.go
index 7d4504d..3c65e2f 100644
--- a/backend/api/generated.go
+++ b/backend/api/generated.go
@@ -97,6 +97,21 @@ type RankingEntry struct {
SubmittedAt int64 `json:"submitted_at"`
}
+// Tournament defines model for Tournament.
+type Tournament struct {
+ Matches []TournamentMatch `json:"matches"`
+}
+
+// TournamentMatch defines model for TournamentMatch.
+type TournamentMatch struct {
+ GameID int `json:"game_id"`
+ Player1 *User `json:"player1,omitempty"`
+ Player1Score *int `json:"player1_score,omitempty"`
+ Player2 *User `json:"player2,omitempty"`
+ Player2Score *int `json:"player2_score,omitempty"`
+ Winner *int `json:"winner,omitempty"`
+}
+
// User defines model for User.
type User struct {
DisplayName string `json:"display_name"`
@@ -176,6 +191,16 @@ type PostLoginJSONBody struct {
Username string `json:"username"`
}
+// GetTournamentParams defines parameters for GetTournament.
+type GetTournamentParams struct {
+ Game1 int `form:"game1" json:"game1"`
+ Game2 int `form:"game2" json:"game2"`
+ Game3 int `form:"game3" json:"game3"`
+ Game4 int `form:"game4" json:"game4"`
+ Game5 int `form:"game5" json:"game5"`
+ Authorization HeaderAuthorization `json:"Authorization"`
+}
+
// PostGamePlayCodeJSONRequestBody defines body for PostGamePlayCode for application/json ContentType.
type PostGamePlayCodeJSONRequestBody PostGamePlayCodeJSONBody
@@ -211,6 +236,9 @@ type ServerInterface interface {
// User login
// (POST /login)
PostLogin(ctx echo.Context) error
+ // Get tournament bracket data
+ // (GET /tournament)
+ GetTournament(ctx echo.Context, params GetTournamentParams) error
}
// ServerInterfaceWrapper converts echo contexts to parameters.
@@ -486,6 +514,71 @@ func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error {
return err
}
+// GetTournament converts echo context to params.
+func (w *ServerInterfaceWrapper) GetTournament(ctx echo.Context) error {
+ var err error
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params GetTournamentParams
+ // ------------- Required query parameter "game1" -------------
+
+ err = runtime.BindQueryParameter("form", true, true, "game1", ctx.QueryParams(), &params.Game1)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game1: %s", err))
+ }
+
+ // ------------- Required query parameter "game2" -------------
+
+ err = runtime.BindQueryParameter("form", true, true, "game2", ctx.QueryParams(), &params.Game2)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game2: %s", err))
+ }
+
+ // ------------- Required query parameter "game3" -------------
+
+ err = runtime.BindQueryParameter("form", true, true, "game3", ctx.QueryParams(), &params.Game3)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game3: %s", err))
+ }
+
+ // ------------- Required query parameter "game4" -------------
+
+ err = runtime.BindQueryParameter("form", true, true, "game4", ctx.QueryParams(), &params.Game4)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game4: %s", err))
+ }
+
+ // ------------- Required query parameter "game5" -------------
+
+ err = runtime.BindQueryParameter("form", true, true, "game5", ctx.QueryParams(), &params.Game5)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game5: %s", err))
+ }
+
+ headers := ctx.Request().Header
+ // ------------- Required header parameter "Authorization" -------------
+ if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found {
+ var Authorization HeaderAuthorization
+ n := len(valueList)
+ if n != 1 {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Authorization, got %d", n))
+ }
+
+ err = runtime.BindStyledParameterWithOptions("simple", "Authorization", valueList[0], &Authorization, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Authorization: %s", err))
+ }
+
+ params.Authorization = Authorization
+ } else {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter Authorization is required, but not found"))
+ }
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetTournament(ctx, params)
+ return err
+}
+
// This is a simple interface which specifies echo.Route addition functions which
// are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration
@@ -522,6 +615,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/games/:game_id/watch/latest_states", wrapper.GetGameWatchLatestStates)
router.GET(baseURL+"/games/:game_id/watch/ranking", wrapper.GetGameWatchRanking)
router.POST(baseURL+"/login", wrapper.PostLogin)
+ router.GET(baseURL+"/tournament", wrapper.GetTournament)
}
@@ -876,6 +970,52 @@ func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseW
return json.NewEncoder(w).Encode(response)
}
+type GetTournamentRequestObject struct {
+ Params GetTournamentParams
+}
+
+type GetTournamentResponseObject interface {
+ VisitGetTournamentResponse(w http.ResponseWriter) error
+}
+
+type GetTournament200JSONResponse struct {
+ Tournament Tournament `json:"tournament"`
+}
+
+func (response GetTournament200JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetTournament401JSONResponse struct{ UnauthorizedJSONResponse }
+
+func (response GetTournament401JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(401)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetTournament403JSONResponse struct{ ForbiddenJSONResponse }
+
+func (response GetTournament403JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(403)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetTournament404JSONResponse struct{ NotFoundJSONResponse }
+
+func (response GetTournament404JSONResponse) VisitGetTournamentResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(404)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// List games
@@ -902,6 +1042,9 @@ type StrictServerInterface interface {
// User login
// (POST /login)
PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error)
+ // Get tournament bracket data
+ // (GET /tournament)
+ GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error)
}
type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc
@@ -1138,34 +1281,61 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error {
return nil
}
+// GetTournament operation middleware
+func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentParams) error {
+ var request GetTournamentRequestObject
+
+ request.Params = params
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetTournament(ctx.Request().Context(), request.(GetTournamentRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetTournament")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetTournamentResponseObject); ok {
+ return validResponse.VisitGetTournamentResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
- "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",
- "DgAA//9Pz5q5YB8AAA==",
+ "H4sIAAAAAAAC/+xa62/bNhD/VwRuQL+osfNY0GWf0q0tOnRF0AeGoQgEWjrbTClSJak6XqH/fThSsl6U",
+ "LadOtxTrh8KWeMd7/O7HO8ZfSCzTTAoQRpOLLySjiqZgQNlvS6AJqIjmZikV+5saJgU+Z4JclC9JSARN",
+ "gVyQy9aqkCj4lDMFCbkwKoeQ6HgJKUVxs85QQBvFxIIURUgyapbRgqYQsWSzAT6s1VdvRyhmwsACFClQ",
+ "tQKdSaHBOvSUJm/gUw7a4LdYCgPCfqRZxllsTZ/caOdlrfdHBXNyQX6Y1MGauLd68kwpWW6VgI4Vy1yU",
+ "cK9AlZsVIXku1YwlCYj737neqgjJa2mey1wk97/ta2mCud2qCMl7UaEGvsHWrd3wdSmBCp0QYlvJDJRh",
+ "DgopaE0XgB/hlqYZR+S8FJ8pZ3XeQg9Wa/h92Ci53iyUsxuIbcKf3UKco31vDTW53RNEnqKYkAIQyLkQ",
+ "qDUkOo9j0JqEZKWkWERU6JWtLcNSkDkaguFgHCKw7lhhfLn5jqBXgvLywXXYcKtW33EnJC9sdXWDkzCd",
+ "cbqORPm2VoXrg2OfpiRXNqeRhliKRLfkTs+nYa88Q9Io+c3S48GF7nEdxuPPaEiac8PQWuh47V737GQ6",
+ "yvIZZ3FrV0cl5eKZlByoLZ+UMhE57dYjZiDVu3D6XjurS3VUKbrG75mSMw7pLvGrchni2FBlIImoaVn7",
+ "89n5+ZOzJ9N+UENy+3ghH9dPz896qK2ptA5rMy5hO/+e1NaudCLkK4RX1IA2CBysBA/aZqBNpGOpINL5",
+ "LGVmu8ci55zOeknbGgGsn6QDZYiXMni0BM5lsJKKJ49+8eHFGtZG6AgrXO7Kut/KaR2a6CbL2l1ZEQ7G",
+ "arOdLwNXNe46dd5k0WZs3i2ZDpgOaFAnuhcZTsUirzi0rMlsmaEtKzY37Wp0L3o6SvWjSEDbl9GdM2mY",
+ "4R3JMjI+RuvkoWFopal9CjXi0TbVl5E3VHxkYvFMGLXup2WshwMYbETX0eJIuhpAuicRI6r0DrxUWluD",
+ "vYPwwWi+k7lCqnL9ReecpyZewnjurnX9gZJ9Gu+2AKX+7XY5XT3jRp9/LjTHYzNZLo88GR3WfrKf9pOx",
+ "2ldMCAfCbesGzihfWK0de/Qsv8ulCH6T4O0GYikiO2m0RCYspQvQkxu5FEc32WKgkaBJytrMOadcexsJ",
+ "TmfA25toQ+fzMWWca1A9mJyc+oKNS/sRQDd2Mly1S0NJrxPY+Fw51M8PqmViLu1A5hiXXPIZNUpqHVRt",
+ "arCCWXB59ZKE5DMo7dr46dHJ0RS9kBkImjFyQU6PpkdT4iZEm+YJIsMVD9hyRwzY7uRlgv0p2E7DtiiN",
+ "WfaDH9n1kol31i2uOwPkyXS61zTTL/fxTGRb813041T6s9CekV4xbQI5D5xEEZKz6SCfbHyetCcrFDrd",
+ "LdQYQO1pkaYUjzlnQrl/EZapnHwpa73YldQD5TTcKde6jrgHDIzLvCfToxJ9aUP8zTKMEme7JTb3EG1I",
+ "vAAT0NJgDyQmSD6Tqh/KpPag40q66eKK0/Wvrlv+12Birw2eymT9FQi5Y3/rmxv8cGlfoBV+eLcx9dbd",
+ "IMxzztdBniXUVGD5zyMM4RGYJQTczqGBDcww1tyqSFej6jZCQsS56dZNtt8DP20c30ZQ3ZG+Cz6nZAxZ",
+ "/SVzVWUGPQqc6MMhrwayoBrlAwU65yaYSxWUQ80w4NyQM47e3rq1/xPc/RLcZu58IDh0sLBQLG+OvXBb",
+ "4RDaIridffSfKNKgOP39cJz9RJOEoQjlV60Ve5FfH4MeNhzXpF9y3mSUmhE1tu74JqVMBNV16wPq8fZ0",
+ "bBi/yl2djUJuec32XYC24feo8bF1xbhrjKyUj8Holc1RUIk8yKM66/iAcONy4S5zhs/hV3bJoc7BjGq9",
+ "kqp9r7N5enxySgbugr7igkdUc3S59VccoHf02siP0Plrwy3+O2r8v9MVp2QMWlsHOyIQhEFT73y4t2D1",
+ "XoMKHHAshkzrJnqInxr31QdjJvuTiU85qHX7NxPH+/1iYoumk4NpOj2YprODafppP03XB66JJmzG/XnC",
+ "UxObV2MKo9YUJNTQh8TjteUzReOPUHlQFMU/AQAA//9Rv7470SQAAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/backend/api/handler.go b/backend/api/handler.go
index 60dab6f..f7595dc 100644
--- a/backend/api/handler.go
+++ b/backend/api/handler.go
@@ -334,6 +334,100 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu
return PostGamePlaySubmit200Response{}, nil
}
+func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *auth.JWTClaims) (GetTournamentResponseObject, error) {
+ gameIDs := []int32{
+ int32(request.Params.Game1),
+ int32(request.Params.Game2),
+ int32(request.Params.Game3),
+ int32(request.Params.Game4),
+ int32(request.Params.Game5),
+ }
+
+ matches := make([]TournamentMatch, 0, 5)
+
+ for _, gameID := range gameIDs {
+ gameRow, err := h.q.GetGameByID(ctx, gameID)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ continue
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ playerRows, err := h.q.ListMainPlayers(ctx, []int32{gameID})
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ var player1, player2 *User
+ if len(playerRows) > 0 {
+ p1 := User{
+ UserID: int(playerRows[0].UserID),
+ Username: playerRows[0].Username,
+ DisplayName: playerRows[0].DisplayName,
+ IconPath: playerRows[0].IconPath,
+ IsAdmin: playerRows[0].IsAdmin,
+ Label: toNullable(playerRows[0].Label),
+ }
+ player1 = &p1
+ }
+ if len(playerRows) > 1 {
+ p2 := User{
+ UserID: int(playerRows[1].UserID),
+ Username: playerRows[1].Username,
+ DisplayName: playerRows[1].DisplayName,
+ IconPath: playerRows[1].IconPath,
+ IsAdmin: playerRows[1].IsAdmin,
+ Label: toNullable(playerRows[1].Label),
+ }
+ player2 = &p2
+ }
+
+ var winnerID *int
+ var player1Score, player2Score *int
+
+ if gameRow.StartedAt.Valid {
+ rankingRows, err := h.q.GetRanking(ctx, gameID)
+ if err == nil && len(rankingRows) > 0 {
+ // Find scores for each player
+ for _, ranking := range rankingRows {
+ userID := int(ranking.User.UserID)
+ score := int(ranking.Submission.CodeSize)
+
+ if player1 != nil && player1.UserID == userID {
+ player1Score = &score
+ if winnerID == nil {
+ winnerID = &userID
+ }
+ }
+ if player2 != nil && player2.UserID == userID {
+ player2Score = &score
+ if winnerID == nil {
+ winnerID = &userID
+ }
+ }
+ }
+ }
+ }
+
+ match := TournamentMatch{
+ GameID: int(gameID),
+ Player1: player1,
+ Player2: player2,
+ Player1Score: player1Score,
+ Player2Score: player2Score,
+ Winner: winnerID,
+ }
+ matches = append(matches, match)
+ }
+
+ return GetTournament200JSONResponse{
+ Tournament: Tournament{
+ Matches: matches,
+ },
+ }, nil
+}
+
func toNullable[T any](p *T) nullable.Nullable[T] {
if p == nil {
return nullable.NewNullNullable[T]()
diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go
index 9c6c41a..c592ed8 100644
--- a/backend/api/handler_wrapper.go
+++ b/backend/api/handler_wrapper.go
@@ -99,6 +99,18 @@ func (h *HandlerWrapper) GetGames(ctx context.Context, request GetGamesRequestOb
return h.impl.GetGames(ctx, request, user)
}
+func (h *HandlerWrapper) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) {
+ user, err := parseJWTClaimsFromAuthorizationHeader(request.Params.Authorization)
+ if err != nil {
+ return GetTournament401JSONResponse{
+ UnauthorizedJSONResponse: UnauthorizedJSONResponse{
+ Message: "Unauthorized",
+ },
+ }, nil
+ }
+ return h.impl.GetTournament(ctx, request, user)
+}
+
func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject) (PostGamePlayCodeResponseObject, error) {
user, err := parseJWTClaimsFromAuthorizationHeader(request.Params.Authorization)
if err != nil {
diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts
index 10dc7ef..6b7ce80 100644
--- a/frontend/app/api/client.ts
+++ b/frontend/app/api/client.ts
@@ -107,6 +107,23 @@ class AuthenticatedApiClient {
return data;
}
+ async getTournament(
+ game1: number,
+ game2: number,
+ game3: number,
+ game4: number,
+ game5: number,
+ ) {
+ const { data, error } = await client.GET("/tournament", {
+ params: {
+ header: this._getAuthorizationHeader(),
+ query: { game1, game2, game3, game4, game5 },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+ }
+
_getAuthorizationHeader() {
return { Authorization: `Bearer ${this.token}` };
}
diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts
index b58b27f..04bfc10 100644
--- a/frontend/app/api/schema.d.ts
+++ b/frontend/app/api/schema.d.ts
@@ -140,6 +140,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/tournament": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get tournament bracket data */
+ get: operations["getTournament"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record<string, never>;
export interface components {
@@ -219,6 +236,21 @@ export interface components {
/** @example echo 'hello world'; */
code: string | null;
};
+ Tournament: {
+ matches: components["schemas"]["TournamentMatch"][];
+ };
+ TournamentMatch: {
+ /** @example 1 */
+ game_id: number;
+ player1?: components["schemas"]["User"];
+ player2?: components["schemas"]["User"];
+ /** @example 1 */
+ player1_score?: number;
+ /** @example 1 */
+ player2_score?: number;
+ /** @example 1 */
+ winner?: number;
+ };
};
responses: {
/** @description Bad request */
@@ -509,4 +541,37 @@ export interface operations {
404: components["responses"]["NotFound"];
};
};
+ getTournament: {
+ parameters: {
+ query: {
+ game1: number;
+ game2: number;
+ game3: number;
+ game4: number;
+ game5: number;
+ };
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Tournament data */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ tournament: components["schemas"]["Tournament"];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
}
diff --git a/frontend/app/components/Gaming/Score.tsx b/frontend/app/components/Gaming/Score.tsx
index 9b6283f..b4a415c 100644
--- a/frontend/app/components/Gaming/Score.tsx
+++ b/frontend/app/components/Gaming/Score.tsx
@@ -13,8 +13,8 @@ export default function Score({ status, score }: Props) {
if (status === "running") {
intervalId = setInterval(() => {
- const maxValue = Math.pow(10, String(score).length) - 1;
- const minValue = Math.pow(10, String(score).length - 1);
+ const maxValue = Math.pow(10, String(score ?? 100).length) - 1;
+ const minValue = Math.pow(10, String(score ?? 100).length - 1);
const randomValue =
Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue;
setDisplayScore(randomValue);
diff --git a/frontend/app/routes/tournament.tsx b/frontend/app/routes/tournament.tsx
new file mode 100644
index 0000000..1e1aa08
--- /dev/null
+++ b/frontend/app/routes/tournament.tsx
@@ -0,0 +1,441 @@
+import type { LoaderFunctionArgs, MetaFunction } from "react-router";
+import { useLoaderData } from "react-router";
+import { ensureUserLoggedIn } from "../.server/auth";
+import { createApiClient } from "../api/client";
+import type { components } from "../api/schema";
+import BorderedContainer from "../components/BorderedContainer";
+import UserIcon from "../components/UserIcon";
+
+export const meta: MetaFunction = () => [
+ { title: "Tournament | iOSDC Japan 2025 Albatross" },
+];
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const { token } = await ensureUserLoggedIn(request);
+ const apiClient = createApiClient(token);
+
+ const url = new URL(request.url);
+ const game1Param = url.searchParams.get("game1");
+ const game2Param = url.searchParams.get("game2");
+ const game3Param = url.searchParams.get("game3");
+ const game4Param = url.searchParams.get("game4");
+ const game5Param = url.searchParams.get("game5");
+ const player1Param = url.searchParams.get("player1");
+ const player2Param = url.searchParams.get("player2");
+ const player3Param = url.searchParams.get("player3");
+ const player4Param = url.searchParams.get("player4");
+ const player5Param = url.searchParams.get("player5");
+ const player6Param = url.searchParams.get("player6");
+
+ if (!game1Param || !game2Param || !game3Param || !game4Param || !game5Param) {
+ throw new Response(
+ "Missing required query parameters: game1, game2, game3, game4, game5",
+ {
+ status: 400,
+ },
+ );
+ }
+ if (
+ !player1Param ||
+ !player2Param ||
+ !player3Param ||
+ !player4Param ||
+ !player5Param ||
+ !player6Param
+ ) {
+ throw new Response(
+ "Missing required query parameters: player1, player2, player3, player4, player5, player6",
+ {
+ status: 400,
+ },
+ );
+ }
+
+ const game1 = Number(game1Param);
+ const game2 = Number(game2Param);
+ const game3 = Number(game3Param);
+ const game4 = Number(game4Param);
+ const game5 = Number(game5Param);
+
+ if (!game1 || !game2 || !game3 || !game4 || !game5) {
+ throw new Response("Invalid game IDs: must be positive integers", {
+ status: 400,
+ });
+ }
+
+ const { tournament } = await apiClient.getTournament(
+ game1,
+ game2,
+ game3,
+ game4,
+ game5,
+ );
+ return {
+ tournament,
+ playerIDs: [
+ Number(player1Param),
+ Number(player2Param),
+ Number(player3Param),
+ Number(player4Param),
+ Number(player5Param),
+ Number(player6Param),
+ ],
+ };
+}
+
+type TournamentMatch = components["schemas"]["TournamentMatch"];
+type User = components["schemas"]["User"];
+
+function Player({ player, rank }: { player: User | null; rank: number }) {
+ return (
+ <BorderedContainer>
+ <div className="flex flex-col items-center gap-2">
+ <span className="text-gray-800 text-md">予選 {rank} 位</span>
+ <span className="font-medium text-lg">{player?.display_name}</span>
+ {player?.icon_path && (
+ <UserIcon
+ iconPath={player.icon_path}
+ displayName={player.display_name}
+ className="w-16 h-16 my-auto"
+ />
+ )}
+ </div>
+ </BorderedContainer>
+ );
+}
+
+function BranchVL({ className = "" }: { className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div></div>
+ <div className={`border-l-4 ${className}`}></div>
+ </div>
+ );
+}
+
+function BranchVR({ className = "" }: { className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div className={`border-r-4 ${className}`}></div>
+ <div></div>
+ </div>
+ );
+}
+
+function BranchVL2({
+ score,
+ className = "",
+}: { score: number | null; className?: string }) {
+ return (
+ <div className="grid grid-cols-3">
+ <div className={`border-r-4 ${className}`}></div>
+ <div className={`border-t-4 p-2 font-bold text-xl ${className}`}>
+ {score}
+ </div>
+ <div className={`border-t-4 ${className}`}></div>
+ </div>
+ );
+}
+
+function BranchVR2({
+ score,
+ className = "",
+}: { score: number | null; className?: string }) {
+ return (
+ <div className="grid grid-cols-3">
+ <div className={`border-t-4 ${className}`}></div>
+ <div className={`border-t-4 p-2 font-bold text-xl ${className}`}>
+ {score}
+ </div>
+ <div className={`border-l-4 ${className}`}></div>
+ </div>
+ );
+}
+
+function BranchV3({ className = "" }: { className?: string }) {
+ return <div className={`border-r-4 ${className}`}></div>;
+}
+
+function BranchH({
+ score,
+ className1,
+ className2,
+ className3,
+}: {
+ score?: number | null;
+ className1: string;
+ className2: string;
+ className3: string;
+}) {
+ return (
+ <div className="grid grid-cols-3">
+ <div className={`border-t-4 ${className1}`}></div>
+ <div className={`border-t-4 ${className2}`}></div>
+ <div className={`border-t-4 p-2 font-bold text-xl ${className3}`}>
+ {score}
+ </div>
+ </div>
+ );
+}
+
+function BranchH2({
+ score,
+ className1,
+ className2,
+ className3,
+}: {
+ score?: number | null;
+ className1: string;
+ className2: string;
+ className3: string;
+}) {
+ return (
+ <div className="grid grid-cols-3">
+ <div
+ className={`border-t-4 p-2 font-bold text-xl text-right ${className1}`}
+ >
+ {score}
+ </div>
+ <div className={`border-t-4 ${className2}`}></div>
+ <div className={`border-t-4 ${className3}`}></div>
+ </div>
+ );
+}
+
+function BranchL({
+ score,
+ className = "",
+}: { score: number | null; className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div></div>
+ <div
+ className={`border-l-4 border-t-4 p-2 font-bold text-xl ${className}`}
+ >
+ {score}
+ </div>
+ </div>
+ );
+}
+
+function BranchR({
+ score,
+ className = "",
+}: { score: number | null; className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div
+ className={`border-r-4 border-t-4 p-2 font-bold text-xl text-right ${className}`}
+ >
+ {score}
+ </div>
+ <div></div>
+ </div>
+ );
+}
+
+function BranchL2({ className = "" }: { className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div className={`border-l-4 ${className}`}></div>
+ <div></div>
+ </div>
+ );
+}
+
+function BranchR2({ className = "" }: { className?: string }) {
+ return (
+ <div className="grid grid-cols-2">
+ <div></div>
+ <div className={`border-r-4 ${className}`}></div>
+ </div>
+ );
+}
+
+function getPlayer(match: TournamentMatch, playerID: number): User | null {
+ if (match.player1?.user_id === playerID) return match.player1;
+ else if (match.player2?.user_id === playerID) return match.player2;
+ else return null;
+}
+
+function getScore(match: TournamentMatch, playerIDs: number[]): number | null {
+ if (match.player1 && playerIDs.includes(match.player1.user_id))
+ return match.player1_score ?? null;
+ if (match.player2 && playerIDs.includes(match.player2.user_id))
+ return match.player2_score ?? null;
+ else return null;
+}
+
+function getBorderColor(match: TournamentMatch, playerIDs: number[]): string {
+ if (!match.winner) {
+ return "border-black";
+ } else if (playerIDs.includes(match.winner)) {
+ return "border-pink-700";
+ } else {
+ return "border-gray-400";
+ }
+}
+
+export default function Tournament() {
+ const { tournament, playerIDs } = useLoaderData<typeof loader>();
+
+ const match1 = tournament.matches[0]!;
+ const match2 = tournament.matches[1]!;
+ const match3 = tournament.matches[2]!;
+ const match4 = tournament.matches[3]!;
+ const match5 = tournament.matches[4]!;
+
+ const playerID1 = playerIDs[0]!;
+ const playerID2 = playerIDs[1]!;
+ const playerID3 = playerIDs[2]!;
+ const playerID4 = playerIDs[3]!;
+ const playerID5 = playerIDs[4]!;
+ const playerID6 = playerIDs[5]!;
+
+ const player5 = getPlayer(match1, playerID5);
+ const player4 = getPlayer(match1, playerID4);
+ const player3 = getPlayer(match2, playerID3);
+ const player6 = getPlayer(match2, playerID6);
+ const player1 = getPlayer(match3, playerID1);
+ const player2 = getPlayer(match4, playerID2);
+
+ return (
+ <div className="p-6 bg-gray-100 min-h-screen">
+ <div className="max-w-4xl mx-auto">
+ <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-iosdc-japan text-center mb-8">
+ iOSDC Japan 2025 Swift Code Battle
+ </h1>
+
+ <div className="grid grid-rows-5">
+ <div className="grid grid-cols-6">
+ <div></div>
+ <div></div>
+ <BranchV3
+ className={getBorderColor(match5, [
+ playerID1,
+ playerID5,
+ playerID4,
+ playerID3,
+ playerID6,
+ playerID2,
+ ])}
+ />
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+ <div className="grid grid-cols-6">
+ <div></div>
+ <BranchVL2
+ score={getScore(match5, [playerID1, playerID5, playerID4])}
+ className={getBorderColor(match5, [
+ playerID1,
+ playerID5,
+ playerID4,
+ ])}
+ />
+ <BranchH
+ className1={getBorderColor(match5, [
+ playerID1,
+ playerID5,
+ playerID4,
+ ])}
+ className2={getBorderColor(match5, [
+ playerID1,
+ playerID5,
+ playerID4,
+ ])}
+ className3={getBorderColor(match5, [
+ playerID1,
+ playerID5,
+ playerID4,
+ ])}
+ />
+ <BranchH
+ className1={getBorderColor(match5, [
+ playerID3,
+ playerID6,
+ playerID2,
+ ])}
+ className2={getBorderColor(match5, [
+ playerID3,
+ playerID6,
+ playerID2,
+ ])}
+ className3={getBorderColor(match5, [
+ playerID3,
+ playerID6,
+ playerID2,
+ ])}
+ />
+ <BranchVR2
+ score={getScore(match5, [playerID3, playerID6, playerID2])}
+ className={getBorderColor(match5, [
+ playerID3,
+ playerID6,
+ playerID2,
+ ])}
+ />
+ <div></div>
+ </div>
+ <div className="grid grid-cols-6">
+ <BranchL
+ score={getScore(match3, [playerID1])}
+ className={getBorderColor(match3, [playerID1])}
+ />
+ <BranchH
+ score={getScore(match3, [playerID5, playerID4])}
+ className1={getBorderColor(match3, [playerID1])}
+ className2={getBorderColor(match3, [playerID5, playerID4])}
+ className3={getBorderColor(match3, [playerID5, playerID4])}
+ />
+ <BranchL2
+ className={getBorderColor(match3, [playerID5, playerID4])}
+ />
+ <BranchR2
+ className={getBorderColor(match4, [playerID3, playerID6])}
+ />
+ <BranchH2
+ score={getScore(match4, [playerID3, playerID6])}
+ className1={getBorderColor(match4, [playerID3, playerID6])}
+ className2={getBorderColor(match4, [playerID3, playerID6])}
+ className3={getBorderColor(match4, [playerID2])}
+ />
+ <BranchR
+ score={getScore(match4, [playerID2])}
+ className={getBorderColor(match4, [playerID2])}
+ />
+ </div>
+ <div className="grid grid-cols-6">
+ <BranchVL className={getBorderColor(match3, [playerID1])} />
+ <BranchL
+ score={getScore(match1, [playerID5])}
+ className={getBorderColor(match1, [playerID5])}
+ />
+ <BranchR
+ score={getScore(match1, [playerID4])}
+ className={getBorderColor(match1, [playerID4])}
+ />
+ <BranchL
+ score={getScore(match2, [playerID3])}
+ className={getBorderColor(match2, [playerID3])}
+ />
+ <BranchR
+ score={getScore(match2, [playerID6])}
+ className={getBorderColor(match2, [playerID6])}
+ />
+ <BranchVR className={getBorderColor(match4, [playerID2])} />
+ </div>
+ <div className="grid grid-cols-6 gap-6">
+ <Player player={player1} rank={1} />
+ <Player player={player5} rank={5} />
+ <Player player={player4} rank={4} />
+ <Player player={player3} rank={3} />
+ <Player player={player6} rank={6} />
+ <Player player={player2} rank={2} />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/openapi/api-server.yaml b/openapi/api-server.yaml
index 098832b..8709db2 100644
--- a/openapi/api-server.yaml
+++ b/openapi/api-server.yaml
@@ -222,6 +222,55 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
+ /tournament:
+ get:
+ operationId: getTournament
+ summary: Get tournament bracket data
+ parameters:
+ - $ref: '#/components/parameters/header_authorization'
+ - in: query
+ name: game1
+ schema:
+ type: integer
+ required: true
+ - in: query
+ name: game2
+ schema:
+ type: integer
+ required: true
+ - in: query
+ name: game3
+ schema:
+ type: integer
+ required: true
+ - in: query
+ name: game4
+ schema:
+ type: integer
+ required: true
+ - in: query
+ name: game5
+ schema:
+ type: integer
+ required: true
+ responses:
+ '200':
+ description: Tournament data
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tournament:
+ $ref: '#/components/schemas/Tournament'
+ required:
+ - tournament
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
components:
parameters:
header_authorization:
@@ -419,3 +468,33 @@ components:
- score
- submitted_at
- code
+ Tournament:
+ type: object
+ properties:
+ matches:
+ type: array
+ items:
+ $ref: '#/components/schemas/TournamentMatch'
+ required:
+ - matches
+ TournamentMatch:
+ type: object
+ properties:
+ game_id:
+ type: integer
+ example: 1
+ player1:
+ $ref: '#/components/schemas/User'
+ player2:
+ $ref: '#/components/schemas/User'
+ player1_score:
+ type: integer
+ example: 1
+ player2_score:
+ type: integer
+ example: 1
+ winner:
+ type: integer
+ example: 1
+ required:
+ - game_id