From 6d078f3adbac920f82f2339d6da0b2b2385e64ef 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 | 16 +++++++++++++++- features/c4game/pages/game.templ | 3 ++- features/common/components/shared.templ | 15 +++++++++++++++ features/snakegame/handlers.go | 18 +++++++++++++++++- features/snakegame/pages/game.templ | 3 ++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index e6f5b52..8c37d01 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -118,11 +118,21 @@ 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 { + return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli()))) + } + + // 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 +150,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..d5aac7b 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strconv" + "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -123,11 +124,21 @@ 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 { + return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli()))) + } + + // 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 +162,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)
}