From 456be68ca2344e1feb11a119053ab862848971af Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 28 Jul 2024 13:07:13 +0900
Subject: refactor(frontend): do not convert property names

---
 frontend/app/routes/dashboard.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'frontend/app/routes/dashboard.tsx')

diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx
index 407dda6..3ad465f 100644
--- a/frontend/app/routes/dashboard.tsx
+++ b/frontend/app/routes/dashboard.tsx
@@ -16,12 +16,12 @@ export default function Dashboard() {
       <div className="p-6 rounded shadow-md max-w-4xl mx-auto">
         <h1 className="text-3xl font-bold mb-4">
           {user.username}{" "}
-          {user.isAdmin && <span className="text-red-500 text-lg">admin</span>}
+          {user.is_admin && <span className="text-red-500 text-lg">admin</span>}
         </h1>
         <h2 className="text-2xl font-semibold mb-2">User</h2>
         <div className="mb-6">
           <ul className="list-disc list-inside">
-            <li>Name: {user.displayName}</li>
+            <li>Name: {user.display_name}</li>
           </ul>
         </div>
         <h2 className="text-2xl font-semibold mb-2">Game</h2>
-- 
cgit v1.2.3-70-g09d2


From 2d5f913a431c4223a16c88551ffff4100ac483c4 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 28 Jul 2024 16:01:41 +0900
Subject: feat: implement game entry

---
 Makefile                             |  14 ++-
 backend/api/generated.go             | 159 +++++++++++++++++++++++++++++++++--
 backend/api/handlers.go              |  93 ++++++++++++++++++--
 backend/db/query.sql.go              | 104 +++++++++++++++++++++++
 backend/go.mod                       |   1 +
 backend/go.sum                       |   6 ++
 backend/query.sql                    |  10 +++
 frontend/app/.server/api/schema.d.ts |  78 +++++++++++++++++
 frontend/app/.server/auth.ts         |  12 ++-
 frontend/app/routes/dashboard.tsx    |  44 ++++++++--
 openapi.yaml                         |  89 ++++++++++++++++++++
 11 files changed, 585 insertions(+), 25 deletions(-)

(limited to 'frontend/app/routes/dashboard.tsx')

diff --git a/Makefile b/Makefile
index 023932c..07f682f 100644
--- a/Makefile
+++ b/Makefile
@@ -10,12 +10,20 @@ up:
 down:
 	docker compose down
 
-.PHONY: api-server-only
-api-server-only:
+.PHONY: api-server-only-build
+api-server-only-build:
+	docker compose build api-server-only
+
+.PHONY: api-server-only-up
+api-server-only-up:
 	docker compose up -d api-server-only
 
+.PHONY: api-server-only-down
+api-server-only-down:
+	docker compose down api-server-only db
+
 .PHONY: psql
-psql: up
+psql:
 	docker compose exec db psql --user=postgres albatross
 
 .PHONY: sqldef-dryrun
diff --git a/backend/api/generated.go b/backend/api/generated.go
index 4455723..921f4b3 100644
--- a/backend/api/generated.go
+++ b/backend/api/generated.go
@@ -17,9 +17,34 @@ import (
 
 	"github.com/getkin/kin-openapi/openapi3"
 	"github.com/labstack/echo/v4"
+	"github.com/oapi-codegen/runtime"
 	strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo"
 )
 
+// Defines values for GameState.
+const (
+	Closed         GameState = "closed"
+	Finished       GameState = "finished"
+	Gaming         GameState = "gaming"
+	Prepare        GameState = "prepare"
+	Starting       GameState = "starting"
+	WaitingEntries GameState = "waiting_entries"
+	WaitingStart   GameState = "waiting_start"
+)
+
+// Game defines model for Game.
+type Game struct {
+	DisplayName     string    `json:"display_name"`
+	DurationSeconds int       `json:"duration_seconds"`
+	GameId          int       `json:"game_id"`
+	Problem         *Problem  `json:"problem,omitempty"`
+	StartedAt       *int      `json:"started_at,omitempty"`
+	State           GameState `json:"state"`
+}
+
+// GameState defines model for Game.State.
+type GameState string
+
 // JwtPayload defines model for JwtPayload.
 type JwtPayload struct {
 	DisplayName string  `json:"display_name"`
@@ -29,6 +54,19 @@ type JwtPayload struct {
 	Username    string  `json:"username"`
 }
 
+// Problem defines model for Problem.
+type Problem struct {
+	Description string `json:"description"`
+	ProblemId   int    `json:"problem_id"`
+	Title       string `json:"title"`
+}
+
+// GetGamesParams defines parameters for GetGames.
+type GetGamesParams struct {
+	PlayerId      *int   `form:"player_id,omitempty" json:"player_id,omitempty"`
+	Authorization string `json:"Authorization"`
+}
+
 // PostLoginJSONBody defines parameters for PostLogin.
 type PostLoginJSONBody struct {
 	Password string `json:"password"`
@@ -40,6 +78,9 @@ type PostLoginJSONRequestBody PostLoginJSONBody
 
 // ServerInterface represents all server handlers.
 type ServerInterface interface {
+	// List games
+	// (GET /games)
+	GetGames(ctx echo.Context, params GetGamesParams) error
 	// User login
 	// (POST /login)
 	PostLogin(ctx echo.Context) error
@@ -50,6 +91,43 @@ type ServerInterfaceWrapper struct {
 	Handler ServerInterface
 }
 
+// GetGames converts echo context to params.
+func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error {
+	var err error
+
+	// Parameter object where we will unmarshal all parameters from the context
+	var params GetGamesParams
+	// ------------- Optional query parameter "player_id" -------------
+
+	err = runtime.BindQueryParameter("form", true, false, "player_id", ctx.QueryParams(), &params.PlayerId)
+	if err != nil {
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter player_id: %s", err))
+	}
+
+	headers := ctx.Request().Header
+	// ------------- Required header parameter "Authorization" -------------
+	if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found {
+		var Authorization string
+		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.GetGames(ctx, params)
+	return err
+}
+
 // PostLogin converts echo context to params.
 func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error {
 	var err error
@@ -87,10 +165,41 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
 		Handler: si,
 	}
 
+	router.GET(baseURL+"/games", wrapper.GetGames)
 	router.POST(baseURL+"/login", wrapper.PostLogin)
 
 }
 
+type GetGamesRequestObject struct {
+	Params GetGamesParams
+}
+
+type GetGamesResponseObject interface {
+	VisitGetGamesResponse(w http.ResponseWriter) error
+}
+
+type GetGames200JSONResponse struct {
+	Games []Game `json:"games"`
+}
+
+func (response GetGames200JSONResponse) VisitGetGamesResponse(w http.ResponseWriter) error {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(200)
+
+	return json.NewEncoder(w).Encode(response)
+}
+
+type GetGames403JSONResponse struct {
+	Message string `json:"message"`
+}
+
+func (response GetGames403JSONResponse) VisitGetGamesResponse(w http.ResponseWriter) error {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(403)
+
+	return json.NewEncoder(w).Encode(response)
+}
+
 type PostLoginRequestObject struct {
 	Body *PostLoginJSONRequestBody
 }
@@ -123,6 +232,9 @@ func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseW
 
 // StrictServerInterface represents all server handlers.
 type StrictServerInterface interface {
+	// List games
+	// (GET /games)
+	GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error)
 	// User login
 	// (POST /login)
 	PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error)
@@ -140,6 +252,31 @@ type strictHandler struct {
 	middlewares []StrictMiddlewareFunc
 }
 
+// GetGames operation middleware
+func (sh *strictHandler) GetGames(ctx echo.Context, params GetGamesParams) error {
+	var request GetGamesRequestObject
+
+	request.Params = params
+
+	handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+		return sh.ssi.GetGames(ctx.Request().Context(), request.(GetGamesRequestObject))
+	}
+	for _, middleware := range sh.middlewares {
+		handler = middleware(handler, "GetGames")
+	}
+
+	response, err := handler(ctx, request)
+
+	if err != nil {
+		return err
+	} else if validResponse, ok := response.(GetGamesResponseObject); ok {
+		return validResponse.VisitGetGamesResponse(ctx.Response())
+	} else if response != nil {
+		return fmt.Errorf("unexpected response type: %T", response)
+	}
+	return nil
+}
+
 // PostLogin operation middleware
 func (sh *strictHandler) PostLogin(ctx echo.Context) error {
 	var request PostLoginRequestObject
@@ -172,14 +309,20 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error {
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"H4sIAAAAAAAC/6RSzY7aQAx+lcjnKAToKbetemHVA1LVU1Uhk5gwdDKejp1lo1XevZqkZElB2kpwIJnJ",
-	"Z/v78RuU3Hh25FSgeAMpj9Tg8Pp81i12lrGKJx/YU1BDw7fKiLfY7Rw2FM/0io23BAU889ElX5ggBe18",
-	"vBENxtXQp2BKdjuPepyXLEyDNcnixEeXnXx9t1R2WDXGzSoPaIUm8J7ZErqIboXCzlQz8HK1nqDGKdUU",
-	"LtBbFZHKLY0+hUC/WxOoguLHNOWqSTp35or3z6kb709UKvSxnXEHjpPV6DD3ye5RA4skkWJwaJMz7ZOn",
-	"7QZSeKEghh0UkGfLLI/s2ZNDb6CAdZZnOaQQ7R0iWliuR788i8ZnDBDVsNtUUMCWRb8OkFEViX7mqovA",
-	"kp2SG2rQe2vKoWpxEnbvO3K7FB5Fzhzmtk+3y9X6XrAP+v/X5mn0fZvfqzS0NFyIZycj71WeP6Ba+RfN",
-	"1xJe4y+7+v9QytjkPvmKpAzG6xj9t7YsSeTQWtsl2OqRnEaqVEU3P+XLB6Q0JIL1P1Fs3AtaUyVloCrO",
-	"Qisfyrk0+h9Bl/6XNBMOyRRnhEvbNBg6KOC7UEjGte77vv8TAAD//y1cIMC8BAAA",
+	"H4sIAAAAAAAC/6xUTW/jNhD9K8K0R8FW4iDY+pai6CKLPRhoe1oExlgcS0wlkssZJesG+u8FqQ9btoqk",
+	"SHKIZGo+3nvzOC+Q29pZQ0YY1i/AeUk1xtfPWFN4Om8dedEUT5VmV+Fha/qv9ANrVxGsY3xyBSnIwYXf",
+	"LF6bAtoUVONRtDVbptwaxZO81W02pmgjVJAPOQXWtNVqEno1F+i83VVUh8CfPe1hDT8tj5yWPaHlpg9r",
+	"U2BBL6S2KJPqv9zc3n66+ZTNwmFB6fiapob1N8gry6QghWfUok2xJSM+aHQ8iX0gICSHnqDvHESJ/LqX",
+	"vTaaS1LwkJ6IibnoJ7oUs03B0/dGe1IBxaDSADCdzmdG+oexpN09Ui6B3Jdn2eChsqj+z7y/2NIkv1ma",
+	"m7jOrdk6lHKastQ1FsTLR1uaxaMrZlN5i6rWZpK5x4ppDN5ZWxGaEN0w+QubXK/mRhhCL1kEKK/KPHQ5",
+	"KXKh9Ih7TuHN0aRn8hLnXrswoimuP0vNieYEk8HgM1r1n950T0RLdca9RzV3ac8EOGk0VEon2C9JhxLa",
+	"7G1o2feGu2qH4i1zEoB5g1XyTLvkbnMPKTyR5ygDZIurRRYwW0cGnYY1rBbZIgt3CaWMwi2D9eNbQfEe",
+	"B1Wj1e9VWEYkn2NASPFYk5BnWH97geAs+N6QP0AKnR8gzHGYcLcwIuozDdu0zy4JFflj+l0jpfX6n9ge",
+	"TpUT39BMyVHlhxDMzhruuFxnWXjk1giZSAudq3QeKy8fuXPJsd7UTKMkWqjm1zZi3O/tODf0Hg+zC4b/",
+	"Y7oT78JXzZLYfdJltCncZKt3cKmJGYszw/5u/U4rRSYZp/2qdYdCb+Ew1o9VuKlr9IeBW0+sTWFZ2aJb",
+	"UM7yjPk2luVrDOmgEMuvVh3eoYZD5mfrp9d8PL26Xs1th3cuvH6vja3nBZx6vf1QP4v9m87W4o/wtzj5",
+	"/yqVrshbpv9Hk+fEvG+q6pBgIyUZCVBJdXa++mg735snrLRKck8q9MKKP9TOQ/1hmon1yTjOqcP/YvJJ",
+	"Z+u2bdt/AwAA//+RpToKFwoAAA==",
 }
 
 // GetSwagger returns the content of the embedded swagger specification file
diff --git a/backend/api/handlers.go b/backend/api/handlers.go
index 162e30a..9856ce9 100644
--- a/backend/api/handlers.go
+++ b/backend/api/handlers.go
@@ -11,6 +11,8 @@ import (
 	"github.com/nsfisis/iosdc-2024-albatross-backend/db"
 )
 
+var _ StrictServerInterface = (*ApiHandler)(nil)
+
 type ApiHandler struct {
 	q *db.Queries
 }
@@ -28,22 +30,20 @@ func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObje
 	if err != nil {
 		return PostLogin401JSONResponse{
 			Message: "Invalid username or password",
-		}, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password")
+		}, nil
 	}
 
 	user, err := h.q.GetUserById(ctx, int32(userId))
 	if err != nil {
 		return PostLogin401JSONResponse{
 			Message: "Invalid username or password",
-		}, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password")
+		}, nil
 	}
 
 	jwt, err := auth.NewJWT(&user)
 	if err != nil {
 		// TODO
-		return PostLogin401JSONResponse{
-			Message: "Internal Server Error",
-		}, echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
+		return nil, echo.NewHTTPError(http.StatusInternalServerError)
 	}
 
 	return PostLogin200JSONResponse{
@@ -51,6 +51,89 @@ func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObje
 	}, nil
 }
 
+func (h *ApiHandler) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) {
+	user := ctx.Value("user").(*auth.JWTClaims)
+	playerId := request.Params.PlayerId
+	if !user.IsAdmin {
+		if playerId == nil || *playerId != user.UserID {
+			return GetGames403JSONResponse{
+				Message: "Forbidden",
+			}, nil
+		}
+	}
+	if playerId == nil {
+		gameRows, err := h.q.ListGames(ctx)
+		if err != nil {
+			return nil, echo.NewHTTPError(http.StatusInternalServerError)
+		}
+		games := make([]Game, len(gameRows))
+		for i, row := range gameRows {
+			var startedAt *int
+			if row.StartedAt.Valid {
+				startedAtTimestamp := int(row.StartedAt.Time.Unix())
+				startedAt = &startedAtTimestamp
+			}
+			var problem *Problem
+			if row.ProblemID.Valid {
+				if !row.Title.Valid || !row.Description.Valid {
+					panic("inconsistent data")
+				}
+				problem = &Problem{
+					ProblemId:   int(row.ProblemID.Int32),
+					Title:       row.Title.String,
+					Description: row.Description.String,
+				}
+			}
+			games[i] = Game{
+				GameId:          int(row.GameID),
+				State:           GameState(row.State),
+				DisplayName:     row.DisplayName,
+				DurationSeconds: int(row.DurationSeconds),
+				StartedAt:       startedAt,
+				Problem:         problem,
+			}
+		}
+		return GetGames200JSONResponse{
+			Games: games,
+		}, nil
+	} else {
+		gameRows, err := h.q.ListGamesForPlayer(ctx, int32(*playerId))
+		if err != nil {
+			return nil, echo.NewHTTPError(http.StatusInternalServerError)
+		}
+		games := make([]Game, len(gameRows))
+		for i, row := range gameRows {
+			var startedAt *int
+			if row.StartedAt.Valid {
+				startedAtTimestamp := int(row.StartedAt.Time.Unix())
+				startedAt = &startedAtTimestamp
+			}
+			var problem *Problem
+			if row.ProblemID.Valid {
+				if !row.Title.Valid || !row.Description.Valid {
+					panic("inconsistent data")
+				}
+				problem = &Problem{
+					ProblemId:   int(row.ProblemID.Int32),
+					Title:       row.Title.String,
+					Description: row.Description.String,
+				}
+			}
+			games[i] = Game{
+				GameId:          int(row.GameID),
+				State:           GameState(row.State),
+				DisplayName:     row.DisplayName,
+				DurationSeconds: int(row.DurationSeconds),
+				StartedAt:       startedAt,
+				Problem:         problem,
+			}
+		}
+		return GetGames200JSONResponse{
+			Games: games,
+		}, nil
+	}
+}
+
 func _assertJwtPayloadIsCompatibleWithJWTClaims() {
 	var c auth.JWTClaims
 	var p JwtPayload
diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go
index 12651d2..20a7dc1 100644
--- a/backend/db/query.sql.go
+++ b/backend/db/query.sql.go
@@ -68,3 +68,107 @@ func (q *Queries) GetUserById(ctx context.Context, userID int32) (User, error) {
 	)
 	return i, err
 }
+
+const listGames = `-- name: ListGames :many
+SELECT game_id, state, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description FROM games
+LEFT JOIN problems ON games.problem_id = problems.problem_id
+`
+
+type ListGamesRow struct {
+	GameID          int32
+	State           string
+	DisplayName     string
+	DurationSeconds int32
+	CreatedAt       pgtype.Timestamp
+	StartedAt       pgtype.Timestamp
+	ProblemID       pgtype.Int4
+	ProblemID_2     pgtype.Int4
+	Title           pgtype.Text
+	Description     pgtype.Text
+}
+
+func (q *Queries) ListGames(ctx context.Context) ([]ListGamesRow, error) {
+	rows, err := q.db.Query(ctx, listGames)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []ListGamesRow
+	for rows.Next() {
+		var i ListGamesRow
+		if err := rows.Scan(
+			&i.GameID,
+			&i.State,
+			&i.DisplayName,
+			&i.DurationSeconds,
+			&i.CreatedAt,
+			&i.StartedAt,
+			&i.ProblemID,
+			&i.ProblemID_2,
+			&i.Title,
+			&i.Description,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const listGamesForPlayer = `-- name: ListGamesForPlayer :many
+SELECT games.game_id, state, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description, game_players.game_id, user_id FROM games
+LEFT JOIN problems ON games.problem_id = problems.problem_id
+JOIN game_players ON games.game_id = game_players.game_id
+WHERE game_players.user_id = $1
+`
+
+type ListGamesForPlayerRow struct {
+	GameID          int32
+	State           string
+	DisplayName     string
+	DurationSeconds int32
+	CreatedAt       pgtype.Timestamp
+	StartedAt       pgtype.Timestamp
+	ProblemID       pgtype.Int4
+	ProblemID_2     pgtype.Int4
+	Title           pgtype.Text
+	Description     pgtype.Text
+	GameID_2        int32
+	UserID          int32
+}
+
+func (q *Queries) ListGamesForPlayer(ctx context.Context, userID int32) ([]ListGamesForPlayerRow, error) {
+	rows, err := q.db.Query(ctx, listGamesForPlayer, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []ListGamesForPlayerRow
+	for rows.Next() {
+		var i ListGamesForPlayerRow
+		if err := rows.Scan(
+			&i.GameID,
+			&i.State,
+			&i.DisplayName,
+			&i.DurationSeconds,
+			&i.CreatedAt,
+			&i.StartedAt,
+			&i.ProblemID,
+			&i.ProblemID_2,
+			&i.Title,
+			&i.Description,
+			&i.GameID_2,
+			&i.UserID,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
diff --git a/backend/go.mod b/backend/go.mod
index e19bfc3..7e47d35 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -17,6 +17,7 @@ require (
 require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
+	github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
 	github.com/cubicdaiya/gonp v1.0.4 // indirect
 	github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index c4c4e16..bc38c89 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,9 +1,13 @@
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
 github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
 github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
 github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
@@ -64,6 +68,7 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -124,6 +129,7 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
 github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
 github.com/sqlc-dev/sqlc v1.26.0 h1:bW6TA1vVdi2lfqsEddN5tSznRMYcWez7hf+AOqSiEp8=
 github.com/sqlc-dev/sqlc v1.26.0/go.mod h1:k2F3RWilLCup3D0XufrzZENCyXjtplALmHDmOt4v5bs=
 github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
diff --git a/backend/query.sql b/backend/query.sql
index 165c2c9..9b038a5 100644
--- a/backend/query.sql
+++ b/backend/query.sql
@@ -8,3 +8,13 @@ SELECT * FROM users
 JOIN user_auths ON users.user_id = user_auths.user_id
 WHERE users.username = $1
 LIMIT 1;
+
+-- name: ListGames :many
+SELECT * FROM games
+LEFT JOIN problems ON games.problem_id = problems.problem_id;
+
+-- name: ListGamesForPlayer :many
+SELECT * FROM games
+LEFT JOIN problems ON games.problem_id = problems.problem_id
+JOIN game_players ON games.game_id = game_players.game_id
+WHERE game_players.user_id = $1;
diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts
index d9ce187..cd87705 100644
--- a/frontend/app/.server/api/schema.d.ts
+++ b/frontend/app/.server/api/schema.d.ts
@@ -64,6 +64,60 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/games": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /** List games */
+        get: {
+            parameters: {
+                query?: {
+                    player_id?: number;
+                };
+                header: {
+                    Authorization: string;
+                };
+                path?: never;
+                cookie?: never;
+            };
+            requestBody?: never;
+            responses: {
+                /** @description List of games */
+                200: {
+                    headers: {
+                        [name: string]: unknown;
+                    };
+                    content: {
+                        "application/json": {
+                            games: components["schemas"]["Game"][];
+                        };
+                    };
+                };
+                /** @description Forbidden */
+                403: {
+                    headers: {
+                        [name: string]: unknown;
+                    };
+                    content: {
+                        "application/json": {
+                            /** @example Forbidden operation */
+                            message: string;
+                        };
+                    };
+                };
+            };
+        };
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
 }
 export type webhooks = Record<string, never>;
 export interface components {
@@ -80,6 +134,30 @@ export interface components {
             /** @example false */
             is_admin: boolean;
         };
+        Game: {
+            /** @example 1 */
+            game_id: number;
+            /**
+             * @example active
+             * @enum {string}
+             */
+            state: "closed" | "waiting_entries" | "waiting_start" | "prepare" | "starting" | "gaming" | "finished";
+            /** @example Game 1 */
+            display_name: string;
+            /** @example 360 */
+            duration_seconds: number;
+            /** @example 946684800 */
+            started_at?: number;
+            problem?: components["schemas"]["Problem"];
+        };
+        Problem: {
+            /** @example 1 */
+            problem_id: number;
+            /** @example Problem 1 */
+            title: string;
+            /** @example This is a problem */
+            description: string;
+        };
     };
     responses: never;
     parameters: never;
diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts
index 822d4b9..988b30c 100644
--- a/frontend/app/.server/auth.ts
+++ b/frontend/app/.server/auth.ts
@@ -39,7 +39,7 @@ export async function isAuthenticated(
     failureRedirect?: never;
     headers?: never;
   },
-): Promise<User | null>;
+): Promise<{ user: User; token: string } | null>;
 export async function isAuthenticated(
   request: Request | Session,
   options: {
@@ -55,7 +55,7 @@ export async function isAuthenticated(
     failureRedirect: string;
     headers?: HeadersInit;
   },
-): Promise<User>;
+): Promise<{ user: User; token: string }>;
 export async function isAuthenticated(
   request: Request | Session,
   options: {
@@ -87,7 +87,7 @@ export async function isAuthenticated(
         failureRedirect: string;
         headers?: HeadersInit;
       } = {},
-): Promise<User | null> {
+): Promise<{ user: User; token: string } | null> {
   // This function's signature should be compatible with `authenticator.isAuthenticated` but TypeScript does not infer it correctly.
   let jwt;
   const { successRedirect, failureRedirect, headers } = options;
@@ -114,5 +114,9 @@ export async function isAuthenticated(
   if (!jwt) {
     return null;
   }
-  return jwtDecode<User>(jwt);
+  const user = jwtDecode<User>(jwt);
+  return {
+    user,
+    token: jwt,
+  };
 }
diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx
index 3ad465f..9836d1b 100644
--- a/frontend/app/routes/dashboard.tsx
+++ b/frontend/app/routes/dashboard.tsx
@@ -1,15 +1,33 @@
 import type { LoaderFunctionArgs } from "@remix-run/node";
+import { Link, useLoaderData } from "@remix-run/react";
 import { isAuthenticated } from "../.server/auth";
-import { useLoaderData } from "@remix-run/react";
+import { apiClient } from "../.server/api/client";
 
 export async function loader({ request }: LoaderFunctionArgs) {
-  return await isAuthenticated(request, {
+  const { user, token } = await isAuthenticated(request, {
     failureRedirect: "/login",
   });
+  const { data, error } = await apiClient.GET("/games", {
+    params: {
+      query: {
+        player_id: user.user_id,
+      },
+      header: {
+        Authorization: `Bearer ${token}`,
+      },
+    },
+  });
+  if (error) {
+    throw new Error(error.message);
+  }
+  return {
+    user,
+    games: data.games,
+  };
 }
 
 export default function Dashboard() {
-  const user = useLoaderData<typeof loader>()!;
+  const { user, games } = useLoaderData<typeof loader>()!;
 
   return (
     <div className="min-h-screen p-8">
@@ -24,10 +42,26 @@ export default function Dashboard() {
             <li>Name: {user.display_name}</li>
           </ul>
         </div>
-        <h2 className="text-2xl font-semibold mb-2">Game</h2>
+        <h2 className="text-2xl font-semibold mb-2">Games</h2>
         <div>
           <ul className="list-disc list-inside">
-            <li>TODO</li>
+            {games.map((game) => (
+              <li key={game.game_id}>
+                {game.display_name}{" "}
+                {game.state === "closed" || game.state === "finished" ? (
+                  <span className="inline-block px-6 py-2 text-gray-400 bg-gray-200 cursor-not-allowed rounded">
+                    Entry
+                  </span>
+                ) : (
+                  <Link
+                    to={`/game/${game.game_id}/play`}
+                    className="inline-block px-6 py-2 text-white bg-blue-500 hover:bg-blue-700 rounded"
+                  >
+                    Entry
+                  </Link>
+                )}
+              </li>
+            ))}
           </ul>
         </div>
       </div>
diff --git a/openapi.yaml b/openapi.yaml
index 786e11e..002b229 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -47,6 +47,46 @@ paths:
                     example: "Invalid credentials"
                 required:
                   - message
+  /games:
+    get:
+      summary: List games
+      parameters:
+        - in: query
+          name: player_id
+          schema:
+            type: integer
+          required: false
+        - in: header
+          name: Authorization
+          schema:
+            type: string
+          required: true
+      responses:
+        '200':
+          description: List of games
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  games:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/Game'
+                required:
+                  - games
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+                    example: "Forbidden operation"
+                required:
+                  - message
 components:
   schemas:
     JwtPayload:
@@ -72,3 +112,52 @@ components:
         - username
         - display_name
         - is_admin
+    Game:
+      type: object
+      properties:
+        game_id:
+          type: integer
+          example: 1
+        state:
+          type: string
+          example: "active"
+          enum:
+            - closed
+            - waiting_entries
+            - waiting_start
+            - prepare
+            - starting
+            - gaming
+            - finished
+        display_name:
+          type: string
+          example: "Game 1"
+        duration_seconds:
+          type: integer
+          example: 360
+        started_at:
+          type: integer
+          example: 946684800
+        problem:
+          $ref: '#/components/schemas/Problem'
+      required:
+        - game_id
+        - state
+        - display_name
+        - duration_seconds
+    Problem:
+      type: object
+      properties:
+        problem_id:
+          type: integer
+          example: 1
+        title:
+          type: string
+          example: "Problem 1"
+        description:
+          type: string
+          example: "This is a problem"
+      required:
+        - problem_id
+        - title
+        - description
-- 
cgit v1.2.3-70-g09d2

