From 8e73d12a703e90ad908962143951178c13d0d6fe Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 20 Feb 2026 23:32:22 +0900 Subject: feat: add user submission history page Allow users to view their own past submissions (code, size, status, timestamp) for each game. Adds API endpoint, backend handler, SQL query, and frontend page with expandable code display. Co-Authored-By: Claude Opus 4.6 --- backend/api/generated.go | 151 +++++++++++++++++++++---- backend/api/handler.go | 38 +++++++ backend/api/handler_test.go | 166 ++++++++++++++++++++++++++-- backend/api/handler_wrapper.go | 10 ++ backend/db/querier.go | 1 + backend/db/query.sql.go | 39 +++++++ backend/query.sql | 5 + frontend/app/App.tsx | 8 ++ frontend/app/api/client.ts | 13 +++ frontend/app/api/schema.d.ts | 75 +++++++++++++ frontend/app/pages/DashboardPage.tsx | 3 + frontend/app/pages/SubmissionsPage.test.tsx | 17 +++ frontend/app/pages/SubmissionsPage.tsx | 126 +++++++++++++++++++++ openapi/api-server.yaml | 64 +++++++++++ typespec/api-server/models.tsp | 11 ++ typespec/api-server/routes.tsp | 9 ++ 16 files changed, 702 insertions(+), 34 deletions(-) create mode 100644 frontend/app/pages/SubmissionsPage.test.tsx create mode 100644 frontend/app/pages/SubmissionsPage.tsx diff --git a/backend/api/generated.go b/backend/api/generated.go index a419123..aee6f3e 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -97,6 +97,16 @@ type RankingEntry struct { SubmittedAt int64 `json:"submitted_at"` } +// Submission defines model for Submission. +type Submission struct { + Code string `json:"code"` + CodeSize int `json:"code_size"` + CreatedAt int64 `json:"created_at"` + GameID int `json:"game_id"` + Status ExecutionStatus `json:"status"` + SubmissionID int `json:"submission_id"` +} + // Tournament defines model for Tournament. type Tournament struct { BracketSize int `json:"bracket_size"` @@ -177,6 +187,9 @@ type ServerInterface interface { // (GET /games/{game_id}/play/latest_state) GetGamePlayLatestState(ctx echo.Context, gameID int) error + // (GET /games/{game_id}/play/submissions) + GetGamePlaySubmissions(ctx echo.Context, gameID int) error + // (POST /games/{game_id}/play/submit) PostGamePlaySubmit(ctx echo.Context, gameID int) error @@ -261,6 +274,22 @@ func (w *ServerInterfaceWrapper) GetGamePlayLatestState(ctx echo.Context) error return err } +// GetGamePlaySubmissions converts echo context to params. +func (w *ServerInterfaceWrapper) GetGamePlaySubmissions(ctx echo.Context) error { + var err error + // ------------- Path parameter "game_id" ------------- + var gameID int + + err = runtime.BindStyledParameterWithOptions("simple", "game_id", ctx.Param("game_id"), &gameID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game_id: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetGamePlaySubmissions(ctx, gameID) + return err +} + // PostGamePlaySubmit converts echo context to params. func (w *ServerInterfaceWrapper) PostGamePlaySubmit(ctx echo.Context) error { var err error @@ -384,6 +413,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/games/:game_id", wrapper.GetGame) router.POST(baseURL+"/games/:game_id/play/code", wrapper.PostGamePlayCode) router.GET(baseURL+"/games/:game_id/play/latest_state", wrapper.GetGamePlayLatestState) + router.GET(baseURL+"/games/:game_id/play/submissions", wrapper.GetGamePlaySubmissions) router.POST(baseURL+"/games/:game_id/play/submit", wrapper.PostGamePlaySubmit) router.GET(baseURL+"/games/:game_id/watch/latest_states", wrapper.GetGameWatchLatestStates) router.GET(baseURL+"/games/:game_id/watch/ranking", wrapper.GetGameWatchRanking) @@ -566,6 +596,52 @@ func (response GetGamePlayLatestState404JSONResponse) VisitGetGamePlayLatestStat return json.NewEncoder(w).Encode(response) } +type GetGamePlaySubmissionsRequestObject struct { + GameID int `json:"game_id"` +} + +type GetGamePlaySubmissionsResponseObject interface { + VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error +} + +type GetGamePlaySubmissions200JSONResponse struct { + Submissions []Submission `json:"submissions"` +} + +func (response GetGamePlaySubmissions200JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions401JSONResponse Error + +func (response GetGamePlaySubmissions401JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions403JSONResponse Error + +func (response GetGamePlaySubmissions403JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions404JSONResponse Error + +func (response GetGamePlaySubmissions404JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type PostGamePlaySubmitRequestObject struct { GameID int `json:"game_id"` Body *PostGamePlaySubmitJSONRequestBody @@ -842,6 +918,9 @@ type StrictServerInterface interface { // (GET /games/{game_id}/play/latest_state) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject) (GetGamePlayLatestStateResponseObject, error) + // (GET /games/{game_id}/play/submissions) + GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject) (GetGamePlaySubmissionsResponseObject, error) + // (POST /games/{game_id}/play/submit) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject) (PostGamePlaySubmitResponseObject, error) @@ -980,6 +1059,31 @@ func (sh *strictHandler) GetGamePlayLatestState(ctx echo.Context, gameID int) er return nil } +// GetGamePlaySubmissions operation middleware +func (sh *strictHandler) GetGamePlaySubmissions(ctx echo.Context, gameID int) error { + var request GetGamePlaySubmissionsRequestObject + + request.GameID = gameID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetGamePlaySubmissions(ctx.Request().Context(), request.(GetGamePlaySubmissionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGamePlaySubmissions") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetGamePlaySubmissionsResponseObject); ok { + return validResponse.VisitGetGamePlaySubmissionsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // PostGamePlaySubmit operation middleware func (sh *strictHandler) PostGamePlaySubmit(ctx echo.Context, gameID int) error { var request PostGamePlaySubmitRequestObject @@ -1164,29 +1268,30 @@ func (sh *strictHandler) GetTournament(ctx echo.Context, tournamentID int) error // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xaTW/bOBP+KwLf96jaThPswbfsIigKpICxTbGHohAoaWKzlUgth4rrGv7vC5L6FiXL", - "SbqLpL454nA4H88zw4/sSSTSTHDgCslyTzDaQErNzxsphdQ/MikykIqB+ZwCIl2D/ql2GZAlQSUZX5PD", - "wScS/s6ZhJgsP1eCX/xSUIRfIVLk4JOb7xDlign+UVGVG73A81RP44ID8YnMOddafYJ5FAEi8clWCr4O", - "KMctSOITxVIQuSK+8YElEIAx2UzWg9XfjCuQnCbFh9qi0nSfvKMp9J2NGWYJ3QW8GO1Ni3NJtR8BQiR4", - "jA0hvegapJZa0xQCFo8M2s978n8J92RJ/jev0zIvcjLXJt5puYNPGAZZHiYsaugMhUiAcj2cUsYDbTlI", - "YxJTkOIx/Z/QGlSoo1LSnf47kyJMID02fVWIHXyCikoFcUCVw2WffH+zFm/qr79d9bBTBqwZnabTfjsz", - "jjzUZnei4YJjFdkGDi8eLvTUPFHMTnXC5pYqQKXnayQ7EBQCqgAjISHAPEyZquPC8yShYQJkqWQO/qQ4", - "aazHbiiaRSbotfkpWDeW0C5Ju0kylpTr+oOeVsu5Ir+qsdVhHmAkWaaXd3qbUL7Oizo0AZW3pXiN50FC", - "Ik2zBILBQCumkgn1r7FMOcdvudVwor3oSKBuG26XSM02mVawZffKidE/Kf/G+PqGK7nrB7p0cwA1tZqC", - "BROLSIVGR3w7PHhEfShsqcHXQdxgGO9ELnXJ4MpBVUmjb6ACZD8GLD/aDoArWWibVHJrc2x2HNU3pSra", - "PErlBz3TpZLnaSBFPtiuVKVigCOdbLTle7W5FdfW6nXAaj/HszYAYgQYIHOOUzHbccpM9K3mcZtsnHs2", - "jTZ9hkG4A3fztuC+mMq0QjwYYZwVeXuaxrejGgWyTm1ujJr0HsWWyfhghLaMc5CBTsPJKKw0l6Y0DK5i", - "70rqpwItJ+4CWSR4kFG1cY9iQOOUcXe2ExpCMqkAj4TCDg7Y5wC2jU01p0fZyuTSvn6wtFrG74VZ0HZE", - "cp2EVEmB6JU7bm8LoXe9ek988gASDWLIYnY5W2ijRQacZowsyeVsMVvoNFG1MTGfa/pYHoGp1TohZov3", - "PiZL8g7MpksXDgmYCY5W+O1iYZsaV0WNp1mWsMjMnH9Fi1gLeDdlp1dac2TolVfHThYHwtfa45C7DXh6", - "JqDyNhQ9c+6BGOKZXuRqcXGSY6MbO3MKcphwbU5aHkMv5zRXGyHZj2r9y39z/XshQxbHwGda7uAXeJjv", - "i6p6OIYMgyVJU1DmCPR5TzQBDb50DzJMaRwz6pRZ8tWO9KrOl2eH3DSgOYB1xtVTcKUXv/r5i+v4I8gH", - "kF5EORfKu2c89lSdFog9CShyGcEQ3Oe6Os/LvXom0IH8lbAn0VVCd3/Ys9nPpIAx/XcR756A/oEzluug", - "6YZ62+iDm5pnPrxSPiTm9iXA8uplrCFoVtjbGntT80L6Q+XbWKy7t1BdAlkl52bxS5HDXolMaxcfrey5", - "YZwbxmvlxJaqaNPqGEdPl3/pKY2egS+qaZhfNI7NtQdNVi2Jk7pJn0iO9nI+5P5qXJL2XWESi4o3iJdC", - "oIZrk66CWk8sx66ESuVnvrxWviRiba97h3det0bkubZFGUXcChk7759Puxrm5eVZofEJ26hHOvO0J5uX", - "zqoKQSJXRyFk//3lZW9hrcPp6AH+A5AzxJ454vV7Gc73rSfc0cv1xgv6lGbefRv+z1q6ar38T3tBH3lj", - "PHfv19m9D4d/AgAA///ukgFcEykAAA==", + "H4sIAAAAAAAC/+xaTW/bOBP+KwLf96jaThPswbfsIigKpICxSbGHohAoaWKzlUgth4rrGv7vC5L6tChZ", + "TtJdOPHNlsjhfDzPzJDUlkQizQQHrpDMtwSjFaTU/LyRUkj9I5MiA6kYmMcpINIl6J9qkwGZE1SS8SXZ", + "7Xwi4e+cSYjJ/Es18KtfDhThN4gU2fnk5gdEuWKC3ymqciMXeJ7qaVxwID6ROedaqk8wjyJAJD5ZS8GX", + "AeW4Bkl8olgKIlfENzawBAIwKpvJ+mX1n3EFktOkeFBrVKrukw80ha6xMcMsoZuAF2870+JcUm1HgBAJ", + "HmNjkF50CVKPWtIUAhYPvLSPt+T/Eh7InPxvWodlWsRkqlW81+N2PmEYZHmYsKghMxQiAcr165QyHmjN", + "QRqVmIIUD8n/jFahQhyVkm70/0yKMIH00PRFMWznE1RUKogDqhwm++THu6V4Vz/97aqDndJhTe80jfbb", + "kXHEoVZ7zxsuOFaebeDw4vFCT80TxexUJ2xuqQJUer5GsgNBIaAKMBISAszDlKnaLzxPEhomQOZK5uCP", + "8pPGeuyGollkhFwbn4J1QwHdJ+l+kIwm5bp+r6XVci7PL2ps7TEPMJIs08s7rU0oX+ZFHhqByttyeI3n", + "XkIiTbMEgl5HK6aSEfmvsUw5x2+Z1TCiveiAo24bZpdIzVaZFrBmD8qJ0T8p/8748oYruek6ujSzBzW1", + "mIIFI5NIhUaHf/d48IT8UOhSg28Pcb1uvNPjEAtUuT3RsVy/CJD97LEnkkCPs+ZARXgiPQsvGOt6ZO+5", + "sT3eb6Tdgtu15ZVWLXtdLr4XudRZmStHNpQ0+g5qwJkHKy5wJQtpo6parY4lgKPApVRFqyeJ/KRnukTy", + "PA2kyHs7AlWJGBep9vhO+Wv5tbV67bDazuGo9eQJBOiBa45j08KeUWaibyUP62T93NFpkEUMg3AD7v7I", + "5o+LscmsGB4MJDU75P1xEt8PShTI9spf460J70FsmYj3emjNOAcZ6DAcjcJKcqlKQ+HK966gfi7QcmSj", + "zSLBg4yqlfstBjROGXdHO6EhJKNq3IAr7Mse/RzAtr6p5nQoW6lc6td1lhbL+IMwC9qmg1wnIVVSIHrl", + "psZbQ+hdLz4SnzyCtKWNzCaXk5lWWmTAacbInFxOZpOZDhNVK+PzqaaP5RGYXK0DYrrojzGZkw9g+lqd", + "OCRgJjjawe9nM1stuSpyPM2yhEVm5vQbWsRawLspOz7Tml1ZJ706NgvY475WG0nuV+DpmYDKW1H0zNYS", + "YognepGr2cVRhg0WZ7PRdKhwbTazHkMv5zRXKyHZz2r9y39z/QchQxbHwCd63M4v8DDdFll1dwgZBkuS", + "pqDMLvPLlmgCGnzpGmSY0mgp6pBZ8tWGdLLO1xeH3DigOYB1xtVzcKUXv/r1i2v/I8hHkF5EORfKe2A8", + "9lQdFog9CShyGUEf3Kc6O0/LTUAm0IH8hbCb/UVCN3/YFvlXUsCo/ruIN89Af8+uxrWXd0O9rfTOTc0z", + "H14pHxJzwBVgebo1VBA0K+yBmD0MO5H6UNk25Ov9g77OVto8PReLN0WO+vwEx3DjrjH8VLjRtnBU0944", + "YzvUujfFn7nz9rijxrVad3bsudk6N1uvlRNrqqJVq9s6WFL+0lMa/RaeVMNlftE4NkeGNFm0RhzViXWJ", + "5GjNzvXlrXFJ2mvPUSwqrkhPhUAN00Z1ZK0b4EM9WSn8zJfXypdELO1VSX/ndWuGvFRblFHEtZCx8+7m", + "uGsVXh48FxKf0UY90ZjnXXeeOqsqBIlcHYSQ/TrvtFtYa3A6ePj1CcgZYi/s8fquGafb1ucPgxdTja9P", + "xhTz/e8q/rOSrlpfzYz7+mTgfv5cvV9n9d7t/gkAAP//fXodcbItAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 8d0d9be..57ae973 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -376,6 +376,44 @@ func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchR }, nil } +func (h *Handler) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject, user *db.User) (GetGamePlaySubmissionsResponseObject, error) { + gameID := request.GameID + + _, err := h.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GetGamePlaySubmissions404JSONResponse{ + Message: "Game not found", + }, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + rows, err := h.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{ + GameID: int32(gameID), + UserID: user.UserID, + }) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + submissions := make([]Submission, len(rows)) + for i, row := range rows { + submissions[i] = Submission{ + SubmissionID: int(row.SubmissionID), + GameID: int(row.GameID), + Code: row.Code, + CodeSize: int(row.CodeSize), + Status: ExecutionStatus(row.Status), + CreatedAt: row.CreatedAt.Time.Unix(), + } + } + + return GetGamePlaySubmissions200JSONResponse{ + Submissions: submissions, + }, nil +} + func (h *Handler) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject, user *db.User) (PostGamePlayCodeResponseObject, error) { gameID := request.GameID diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 2415710..a28d605 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -16,17 +16,18 @@ import ( // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier - getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) - listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) - listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) - deleteSessionFunc func(ctx context.Context, sessionID string) error - getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) - updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error - getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) - getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) - getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) - listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) - listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) + listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) + deleteSessionFunc func(ctx context.Context, sessionID string) error + getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) + updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error + getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) + getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) + getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) + listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) + listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) + getSubmissionsByGameIDAndUserIDFunc func(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -85,6 +86,13 @@ func (m *mockQuerier) GetLatestStatesOfMainPlayers(ctx context.Context, gameID i return nil, nil } +func (m *mockQuerier) GetSubmissionsByGameIDAndUserID(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { + if m.getSubmissionsByGameIDAndUserIDFunc != nil { + return m.getSubmissionsByGameIDAndUserIDFunc(ctx, arg) + } + return nil, nil +} + func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) { if m.getTournamentByIDFunc != nil { return m.getTournamentByIDFunc(ctx, tournamentID) @@ -137,6 +145,142 @@ func (m *mockAuthenticator) Login(_ context.Context, _, _ string) (int, error) { return m.loginResult, m.loginErr } +func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ + GameID: 999, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGamePlaySubmissions404JSONResponse); !ok { + t.Errorf("expected 404 response, got %T", resp) + } +} + +func TestGetGamePlaySubmissions_Empty(t *testing.T) { + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ + GameID: 1, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetGamePlaySubmissions200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if len(okResp.Submissions) != 0 { + t.Errorf("expected 0 submissions, got %d", len(okResp.Submissions)) + } +} + +func TestGetGamePlaySubmissions_WithSubmissions(t *testing.T) { + now := time.Now() + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil + }, + getSubmissionsByGameIDAndUserIDFunc: func(_ context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { + if arg.GameID != 1 || arg.UserID != 42 { + t.Errorf("unexpected query params: game_id=%d, user_id=%d", arg.GameID, arg.UserID) + } + return []db.Submission{ + { + SubmissionID: 10, + GameID: 1, + UserID: 42, + Code: " )} + + {(params) => ( + + + + )} + {(params) => ( diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts index c9647ba..db5f8c8 100644 --- a/frontend/app/api/client.ts +++ b/frontend/app/api/client.ts @@ -85,6 +85,19 @@ class AuthenticatedApiClient { return data; } + async getGamePlaySubmissions(gameId: number) { + const { data, error } = await client.GET( + "/games/{game_id}/play/submissions", + { + params: { + path: { game_id: gameId }, + }, + }, + ); + if (error) throw new Error(error.message); + return data; + } + async getGameWatchRanking(gameId: number) { const { data, error } = await client.GET("/games/{game_id}/watch/ranking", { params: { diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts index b891bfa..e720f52 100644 --- a/frontend/app/api/schema.d.ts +++ b/frontend/app/api/schema.d.ts @@ -68,6 +68,22 @@ export interface paths { patch?: never; trace?: never; }; + "/games/{game_id}/play/submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGamePlaySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/games/{game_id}/play/submit": { parameters: { query?: never; @@ -222,6 +238,14 @@ export interface components { submitted_at: number; code: string | null; }; + Submission: { + submission_id: number; + game_id: number; + code: string; + code_size: number; + status: components["schemas"]["ExecutionStatus"]; + created_at: number; + }; Tournament: { tournament_id: number; display_name: string; @@ -458,6 +482,57 @@ export interface operations { }; }; }; + getGamePlaySubmissions: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request has succeeded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + submissions: components["schemas"]["Submission"][]; + }; + }; + }; + /** @description Access is unauthorized. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access is forbidden. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The server cannot find the requested resource. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; postGamePlaySubmit: { parameters: { query?: never; diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx index 708a867..3191f1b 100644 --- a/frontend/app/pages/DashboardPage.tsx +++ b/frontend/app/pages/DashboardPage.tsx @@ -74,6 +74,9 @@ export default function DashboardPage() { 観戦 + + 提出履歴 + ))} diff --git a/frontend/app/pages/SubmissionsPage.test.tsx b/frontend/app/pages/SubmissionsPage.test.tsx new file mode 100644 index 0000000..f01f4c9 --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.test.tsx @@ -0,0 +1,17 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import SubmissionsPage from "./SubmissionsPage"; + +afterEach(() => { + cleanup(); +}); + +describe("SubmissionsPage", () => { + test("shows loading state initially", () => { + render(); + expect(screen.getByText("Loading...")).toBeDefined(); + }); +}); diff --git a/frontend/app/pages/SubmissionsPage.tsx b/frontend/app/pages/SubmissionsPage.tsx new file mode 100644 index 0000000..2c3329d --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import BorderedContainerWithCaption from "../components/BorderedContainerWithCaption"; +import NavigateLink from "../components/NavigateLink"; +import SubmitStatusLabel from "../components/SubmitStatusLabel"; +import { APP_NAME } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type Submission = components["schemas"]["Submission"]; + +export default function SubmissionsPage({ gameId }: { gameId: string }) { + usePageTitle(`Submissions | ${APP_NAME}`); + + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + + const numericGameId = Number(gameId); + + useEffect(() => { + const apiClient = createApiClient(); + apiClient + .getGamePlaySubmissions(numericGameId) + .then(({ submissions }) => setSubmissions(submissions)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [numericGameId]); + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+ +
+ {submissions.length === 0 ? ( +

提出履歴はありません

+ ) : ( +
    + {submissions.map((s) => ( +
  • +
    +
    + + + {s.code_size} + + bytes + + +
    +
    + + {formatDate(s.created_at)} + + +
    +
    + {expandedId === s.submission_id && ( +
    +											{s.code}
    +										
    + )} +
  • + ))} +
+ )} +
+
+ 対戦に戻る + ダッシュボードに戻る +
+ ); +} + +function StatusBadge({ + status, +}: { + status: components["schemas"]["ExecutionStatus"]; +}) { + const colorClass = + status === "success" + ? "bg-green-100 text-green-800" + : status === "running" + ? "bg-yellow-100 text-yellow-800" + : status === "none" + ? "bg-gray-100 text-gray-800" + : "bg-red-100 text-red-800"; + + return ( + + + + ); +} + +function formatDate(unixTimestamp: number): string { + const date = new Date(unixTimestamp * 1000); + return date.toLocaleString("ja-JP", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/openapi/api-server.yaml b/openapi/api-server.yaml index ed535d3..9749f25 100644 --- a/openapi/api-server.yaml +++ b/openapi/api-server.yaml @@ -153,6 +153,47 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /games/{game_id}/play/submissions: + get: + operationId: getGamePlaySubmissions + parameters: + - name: game_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + properties: + submissions: + type: array + items: + $ref: '#/components/schemas/Submission' + required: + - submissions + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /games/{game_id}/play/submit: post: operationId: postGamePlaySubmit @@ -502,6 +543,29 @@ components: code: type: string nullable: true + Submission: + type: object + required: + - submission_id + - game_id + - code + - code_size + - status + - created_at + properties: + submission_id: + type: integer + game_id: + type: integer + code: + type: string + code_size: + type: integer + status: + $ref: '#/components/schemas/ExecutionStatus' + created_at: + type: integer + x-go-type: int64 Tournament: type: object required: diff --git a/typespec/api-server/models.tsp b/typespec/api-server/models.tsp index 6605767..570977c 100644 --- a/typespec/api-server/models.tsp +++ b/typespec/api-server/models.tsp @@ -119,6 +119,17 @@ model TournamentEntry { seed: integer; } +model Submission { + submission_id: integer; + game_id: integer; + code: string; + code_size: integer; + status: ExecutionStatus; + + @extension("x-go-type", "int64") + created_at: integer; +} + model TournamentMatch { tournament_match_id: integer; round: integer; diff --git a/typespec/api-server/routes.tsp b/typespec/api-server/routes.tsp index a67ab8f..4f90822 100644 --- a/typespec/api-server/routes.tsp +++ b/typespec/api-server/routes.tsp @@ -88,6 +88,15 @@ op postGamePlaySubmit( @statusCode statusCode: 200; } | UnauthorizedError | ForbiddenError | NotFoundError; +@route("/games/{game_id}/play/submissions") +@get +@operationId("getGamePlaySubmissions") +op getGamePlaySubmissions(@path game_id: integer): { + @body body: { + submissions: Submission[]; + }; +} | UnauthorizedError | ForbiddenError | NotFoundError; + // ---------- Watch ---------- @route("/games/{game_id}/watch/ranking") -- cgit v1.3.1