Show user's active games on home page after login

This commit is contained in:
Ryan Hamamura
2026-01-14 17:11:27 -10:00
parent 2153b6ad75
commit d96f7dcc29
5 changed files with 250 additions and 1 deletions

View File

@@ -209,6 +209,60 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
return items, nil 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 const updateGame = `-- name: UpdateGame :exec
UPDATE games UPDATE games
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP

View File

@@ -29,3 +29,17 @@ SELECT g.* FROM games g
JOIN game_players gp ON g.id = gp.game_id JOIN game_players gp ON g.id = gp.game_id
WHERE gp.user_id = ? WHERE gp.user_id = ?
ORDER BY g.updated_at DESC; 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;

79
main.go
View File

@@ -43,6 +43,24 @@ func main() {
username := c.Session().GetString("username") username := c.Session().GetString("username")
isLoggedIn := userID != "" 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("") nickname := c.Signal("")
if isLoggedIn { if isLoggedIn {
nickname = c.Signal(username) nickname = c.Signal(username)
@@ -72,6 +90,7 @@ func main() {
isLoggedIn, isLoggedIn,
username, username,
logout.OnClick(), logout.OnClick(),
userGames,
) )
}) })
}) })
@@ -489,4 +508,64 @@ const gameCSS = `
border-radius: 8px; border-radius: 8px;
margin-bottom: 1rem; 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);
}
` `

101
ui/gamelist.go Normal file
View File

@@ -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)
}

View File

@@ -4,7 +4,7 @@ import (
"github.com/ryanhamamura/via/h" "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 var authSection h.H
if isLoggedIn { if isLoggedIn {
authSection = AuthHeader(username, logoutClick) authSection = AuthHeader(username, logoutClick)
@@ -35,6 +35,7 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
createGameClick, createGameClick,
), ),
), ),
GameList(userGames),
), ),
) )
} }