From 90a3d9e5a5ada592e817b5ace6bbcfaccff0170a 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 | 17 ++++++++++++++- features/c4game/pages/game.templ | 3 ++- features/common/components/shared.templ | 15 +++++++++++++ features/snakegame/handlers.go | 19 ++++++++++++++++- features/snakegame/pages/game.templ | 3 ++- sse/signals.go | 28 +++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 sse/signals.go diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index e6f5b52..d272c05 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -17,6 +17,7 @@ import ( "github.com/ryanhamamura/games/db/repository" "github.com/ryanhamamura/games/features/c4game/pages" "github.com/ryanhamamura/games/sessions" + appsse "github.com/ryanhamamura/games/sse" ) // c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors. @@ -118,11 +119,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 appsse.SendPing(sse) + } + + // 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 +151,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..5600b99 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" @@ -15,6 +16,7 @@ import ( "github.com/ryanhamamura/games/features/snakegame/pages" "github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/snake" + appsse "github.com/ryanhamamura/games/sse" ) func snakeChatColor(slot int) string { @@ -123,11 +125,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 appsse.SendPing(sse) + } + + // 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 +163,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)
} diff --git a/sse/signals.go b/sse/signals.go new file mode 100644 index 0000000..f494b14 --- /dev/null +++ b/sse/signals.go @@ -0,0 +1,28 @@ +// Package sse provides helpers for SSE signal handling. +package sse + +import ( + "encoding/json" + "time" + + "github.com/starfederation/datastar-go/datastar" +) + +// Signals holds client-side state managed via SSE. +type Signals struct { + LastPing int64 `json:"lastPing,omitempty"` +} + +// SendPing sends a heartbeat signal with the current timestamp. +func SendPing(sse *datastar.ServerSentEventGenerator) error { + return PatchSignals(sse, Signals{LastPing: time.Now().UnixMilli()}) +} + +// PatchSignals sends a signals patch to the client. +func PatchSignals(sse *datastar.ServerSentEventGenerator, s Signals) error { + data, err := json.Marshal(s) + if err != nil { + return err + } + return sse.PatchSignals(data) +}