diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go index 2cdef54..9d4d9dc 100644 --- a/db/gen/games.sql.go +++ b/db/gen/games.sql.go @@ -209,6 +209,60 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ( return items, nil } +const getUserActiveGames = `-- name: GetUserActiveGames :many +SELECT + g.id, + g.status, + g.current_turn, + g.updated_at, + gp_user.color as my_color, + gp_opponent.nickname as opponent_nickname +FROM games g +JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ? +LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot +WHERE g.status < 2 +ORDER BY g.updated_at DESC +` + +type GetUserActiveGamesRow struct { + ID string + Status int64 + CurrentTurn int64 + UpdatedAt sql.NullTime + MyColor int64 + OpponentNickname sql.NullString +} + +func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveGamesRow, error) { + rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserActiveGamesRow + for rows.Next() { + var i GetUserActiveGamesRow + if err := rows.Scan( + &i.ID, + &i.Status, + &i.CurrentTurn, + &i.UpdatedAt, + &i.MyColor, + &i.OpponentNickname, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGame = `-- name: UpdateGame :exec UPDATE games SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP diff --git a/db/queries/games.sql b/db/queries/games.sql index e092705..fa4c12f 100644 --- a/db/queries/games.sql +++ b/db/queries/games.sql @@ -29,3 +29,17 @@ SELECT g.* FROM games g JOIN game_players gp ON g.id = gp.game_id WHERE gp.user_id = ? ORDER BY g.updated_at DESC; + +-- name: GetUserActiveGames :many +SELECT + g.id, + g.status, + g.current_turn, + g.updated_at, + gp_user.color as my_color, + gp_opponent.nickname as opponent_nickname +FROM games g +JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ? +LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot +WHERE g.status < 2 +ORDER BY g.updated_at DESC; diff --git a/main.go b/main.go index 483992c..b4cd36b 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,24 @@ func main() { username := c.Session().GetString("username") isLoggedIn := userID != "" + var userGames []ui.GameListItem + if isLoggedIn { + ctx := context.Background() + games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true}) + if err == nil { + for _, g := range games { + isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor + userGames = append(userGames, ui.GameListItem{ + ID: g.ID, + Status: int(g.Status), + OpponentName: g.OpponentNickname.String, + IsMyTurn: isMyTurn, + LastPlayed: g.UpdatedAt.Time, + }) + } + } + } + nickname := c.Signal("") if isLoggedIn { nickname = c.Signal(username) @@ -72,6 +90,7 @@ func main() { isLoggedIn, username, logout.OnClick(), + userGames, ) }) }) @@ -489,4 +508,64 @@ const gameCSS = ` border-radius: 8px; margin-bottom: 1rem; } + + .game-list { + margin-top: 2rem; + text-align: left; + } + + .game-list h3 { + margin-bottom: 1rem; + text-align: center; + } + + .game-list-items { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .game-entry { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--pico-muted-background); + border-radius: 8px; + text-decoration: none; + color: inherit; + transition: background 0.2s; + } + + .game-entry:hover { + background: var(--pico-secondary-background); + } + + .game-entry-main { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .opponent-name { + font-weight: bold; + } + + .game-status { + font-size: 0.875rem; + } + + .game-status.your-turn { + color: #22c55e; + font-weight: bold; + } + + .game-status.waiting { + color: var(--pico-muted-color); + } + + .time-ago { + font-size: 0.75rem; + color: var(--pico-muted-color); + } ` diff --git a/ui/gamelist.go b/ui/gamelist.go new file mode 100644 index 0000000..5732bf9 --- /dev/null +++ b/ui/gamelist.go @@ -0,0 +1,101 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/ryanhamamura/via/h" +) + +type GameListItem struct { + ID string + Status int + OpponentName string + IsMyTurn bool + LastPlayed time.Time +} + +func GameList(games []GameListItem) h.H { + if len(games) == 0 { + return nil + } + + var items []h.H + for _, g := range games { + items = append(items, gameListEntry(g)) + } + + listItems := []h.H{h.Class("game-list-items")} + listItems = append(listItems, items...) + + return h.Div(h.Class("game-list"), + h.H3(h.Text("Your Games")), + h.Div(listItems...), + ) +} + +func gameListEntry(g GameListItem) h.H { + statusText, statusClass := getStatusDisplay(g) + + return h.A( + h.Href("/game/"+g.ID), + h.Class("game-entry"), + h.Div(h.Class("game-entry-main"), + h.Span(h.Class("opponent-name"), h.Text(getOpponentDisplay(g))), + h.Span(h.Class("game-status "+statusClass), h.Text(statusText)), + ), + h.Div(h.Class("game-entry-meta"), + h.Span(h.Class("time-ago"), h.Text(formatTimeAgo(g.LastPlayed))), + ), + ) +} + +func getStatusDisplay(g GameListItem) (string, string) { + switch g.Status { + case 0: // Waiting + return "Waiting for opponent", "waiting" + case 1: // In progress + if g.IsMyTurn { + return "Your turn!", "your-turn" + } + return "Opponent's turn", "opponent-turn" + } + return "", "" +} + +func getOpponentDisplay(g GameListItem) string { + if g.OpponentName == "" { + return "Waiting for opponent..." + } + return "vs " + g.OpponentName +} + +func formatTimeAgo(t time.Time) string { + if t.IsZero() { + return "" + } + duration := time.Since(t) + + if duration < time.Minute { + return "just now" + } + if duration < time.Hour { + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + } + if duration < 24*time.Hour { + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + } + days := int(duration.Hours() / 24) + if days == 1 { + return "yesterday" + } + return fmt.Sprintf("%d days ago", days) +} diff --git a/ui/lobby.go b/ui/lobby.go index 7b7127d..484ed3d 100644 --- a/ui/lobby.go +++ b/ui/lobby.go @@ -4,7 +4,7 @@ import ( "github.com/ryanhamamura/via/h" ) -func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H) h.H { +func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem) h.H { var authSection h.H if isLoggedIn { authSection = AuthHeader(username, logoutClick) @@ -35,6 +35,7 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn createGameClick, ), ), + GameList(userGames), ), ) }