From 45e6e08a5cc8e68433e424a1dd66b65209e00021 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:53:20 -1000 Subject: [PATCH] feat: add connection status indicator with SSE heartbeat - Add ConnectionIndicator component showing green/red dot - Send lastPing signal every 15 seconds via SSE - Indicator turns red if no ping received in 20 seconds - Gives users confidence the live connection is active --- features/c4game/handlers.go | 20 +++++++++++++++++++- features/c4game/pages/game.templ | 3 ++- features/common/components/shared.templ | 15 +++++++++++++++ features/snakegame/handlers.go | 22 +++++++++++++++++++++- features/snakegame/pages/game.templ | 3 ++- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index e6f5b52..5250fc8 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -1,6 +1,7 @@ package c4game import ( + "encoding/json" "fmt" "net/http" "strconv" @@ -118,11 +119,24 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg)) } - // Send initial render + sendPing := func() error { + data, _ := json.Marshal(struct { + LastPing int64 `json:"lastPing"` + }{time.Now().UnixMilli()}) + return sse.PatchSignals(data) + } + + // Send initial render and ping + if err := sendPing(); err != nil { + return + } if err := patchAll(); err != nil { return } + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh) @@ -140,6 +154,10 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag select { case <-ctx.Done(): return + case <-heartbeat.C: + if err := sendPing(); err != nil { + return + } case <-gameCh: if err := patchAll(); err != nil { return diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index c5d9f11..16204cf 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -15,9 +15,10 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c @layouts.Base("Connect 4") {
+ @sharedcomponents.ConnectionIndicator() @GameContent(g, myColor, messages, chatCfg)
} diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ index 5567f93..76d67b6 100644 --- a/features/common/components/shared.templ +++ b/features/common/components/shared.templ @@ -44,6 +44,21 @@ templ NicknamePrompt(returnPath string) { } +// ConnectionIndicator shows a small dot indicating SSE connection status. +// It requires a `lastPing` signal (unix ms timestamp) to be set by the server. +templ ConnectionIndicator() { +
+ +
+} + templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {

Join Game

diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index afb6f3a..439c81b 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -1,9 +1,11 @@ package snakegame import ( + "encoding/json" "fmt" "net/http" "strconv" + "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -123,11 +125,24 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID)) } - // Send initial render + sendPing := func() error { + data, _ := json.Marshal(struct { + LastPing int64 `json:"lastPing"` + }{time.Now().UnixMilli()}) + return sse.PatchSignals(data) + } + + // Send initial render and ping + if err := sendPing(); err != nil { + return + } if err := patchAll(); err != nil { return } + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + // Subscribe to game updates via NATS gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh) @@ -151,6 +166,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess case <-ctx.Done(): return + case <-heartbeat.C: + if err := sendPing(); err != nil { + return + } + case <-gameCh: // Drain backed-up game updates for { diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 378690c..f8fe244 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -32,11 +32,12 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg @layouts.Base("Snake") {
+ @components.ConnectionIndicator() @GameContent(sg, mySlot, messages, chatCfg, gameID)
}