# Plan Status: **ready_for_implement** Goal: implement `ROADMAP.md` Priority 2 — the page refreshes itself whenever a new valid MQTT power message arrives, using Server-Sent Events (SSE). T-001..T-003 (Priority 1) are done and stay unchanged in behavior. ## Scope - Standard library only; no new dependencies. - Out of scope: live-reading line, WebSocket, changes to the status rule, JSON contract of `/api/status`, or 1-minute sampling. ## Design decisions (from ROADMAP.md Priority 2) - Event source: a valid payload accepted by `Sampler.Receive` (about every 10 s). Invalid payloads return an error and never notify. - Hub: new `internal/sensor/hub.go` with a concurrency-safe `Hub` (zero value usable): - `Subscribe() (events <-chan struct{}, cancel func())`: each subscriber gets a channel with buffer 1. - `Notify()`: non-blocking send to every subscriber (drop if the buffer is full, so a slow client never blocks MQTT handling and bursts coalesce). - `cancel` removes the subscriber; safe to call more than once. - `Sampler` gets an optional exported field `OnReceive func()`, set before use (documented as such), called after a valid payload is stored and the mutex is released. Zero value stays ready to use. `main.go` sets `sampler.OnReceive = hub.Notify`. - Endpoint `GET /api/events` (`text/event-stream`, `Cache-Control: no-store`): - On connect: flush headers and send an initial `event: update` so the client syncs immediately (covers reconnects). - Per notification: write `event: update\ndata: {}\n\n` and flush. - Keep-alive: SSE comment line `: keep-alive\n\n` every 25 s (interval a package-level variable so tests can shorten it). - Clear the write deadline for this stream with `http.NewResponseController(w).SetWriteDeadline(time.Time{})` so the server-wide 10 s `WriteTimeout` does not cut it; keep `WriteTimeout` as is for other routes. If the ResponseWriter does not support flushing, return 500. - Exit on `r.Context().Done()` (client gone or server shutdown) and call `cancel`. - Graceful shutdown: `server.Shutdown` waits for active handlers; SSE handlers must also stop on shutdown. Register `server.RegisterOnShutdown(cancelStreams)` with a context the handler selects on (e.g. a base context passed to `NewHandler`), so shutdown is not delayed to the 5 s timeout. - `NewHandler` signature becomes `NewHandler(snapshot func() status.Result, events *sensor.Hub)`; `web` imports `sensor` only for the Hub type (or accept an interface `Subscribe() (<-chan struct{}, func())` to avoid the import; prefer the interface). - Page (`index.html`): - Open `new EventSource('/api/events')`; on `update` call `refresh()`. `EventSource` reconnects automatically; the `open` event after a reconnect triggers a refresh via the initial `update`. - Keep `setInterval(refresh, 60000)` as a fallback. Coalesce overlapping refreshes (skip or reuse if one is in flight) so bursts do not stack requests. - If `EventSource` is unavailable, rely on polling only. ## Acceptance Criteria - A valid MQTT message triggers an SSE `update` event to all connected clients within the same moment; the page then re-fetches `/api/status`. - Invalid payloads produce no event. - A new connection receives an immediate `update`; idle connections receive a `: keep-alive` comment at the configured interval. - The stream survives longer than the 10 s `WriteTimeout`; other routes keep it. - A slow or stuck subscriber never blocks `Receive`/MQTT handling or other subscribers. - Disconnected clients are removed from the hub (no goroutine or subscriber leak); shutdown closes streams promptly. - `/api/status` JSON, status rule, and 60 s polling fallback unchanged. - `go fmt ./...`, `go vet ./...`, `go test ./...` (also `-race` for sensor and web) pass. ## Tasks ### T-004 — SSE push so the page refreshes on each MQTT message Tests first, then implementation. - `internal/sensor/hub_test.go`, `internal/sensor/hub.go`: subscribe/notify delivery to multiple subscribers, coalescing when buffer is full (Notify never blocks), cancel removes subscriber and is idempotent, concurrent use under `-race`. - `internal/sensor/sampler_test.go`, `internal/sensor/sampler.go`: `OnReceive` called once per valid payload, not called for invalid payload, nil `OnReceive` is safe. - `internal/web/handler_test.go`, `internal/web/handler.go`: `/api/events` headers (`text/event-stream`, `no-store`), initial `update`, `update` after a notification, keep-alive with a shortened interval, handler returns and cancels on client context cancel, non-flushable writer -> 500; existing tests updated for the new `NewHandler` signature; page test asserts `EventSource` and `/api/events` are present and `60000` fallback remains. - `internal/web/static/index.html`: EventSource wiring, in-flight refresh coalescing, polling fallback. - `main.go`, `main_test.go`: create the hub, set `sampler.OnReceive`, pass to `NewHandler`, shutdown handling for streams; keep `WriteTimeout`; test that shutdown completes promptly with an open stream if feasible. - Documentation (explicit scope): `README.md` — describe `GET /api/events` (event format, keep-alive), that the page refreshes on each MQTT message with 60 s polling as fallback, and update the statements at lines ~13 and ~76 that say the page only refreshes every 60 s; note that reverse proxies must not buffer `text/event-stream`. Update code comments in `main.go` (`loadConfig` comment says the page always polls every 60 s) and package docs for `Hub` and `OnReceive`. `ROADMAP.md` already updated. - Files to change: `internal/sensor/hub.go`, `internal/sensor/hub_test.go`, `internal/sensor/sampler.go`, `internal/sensor/sampler_test.go`, `internal/web/handler.go`, `internal/web/handler_test.go`, `internal/web/static/index.html`, `main.go`, `main_test.go`, `README.md`. Stage new files with `git add`. - Suggested commit: `feat(web): refresh the laundry page instantly on each MQTT message via server-sent events`