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.
Goal: implement `ROADMAP.md` Priority 3 — show the "Laundry done" status text in green (`#1a7f37`). T-001..T-004 are done and 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`.
- 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.
- `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`
- CSS-only change in the page plus docs and a test. No backend, JSON contract, status rule or message text changes.
- Out of scope: dark mode, other state colors, layout changes.
## T-005 — Green "Laundry done" text
Background: `internal/web/static/index.html` sets the `#status` element's class to `data.state`, so the done state gets class `done` (currently unstyled, i.e. default `#111`).
### Files to change
1. `internal/web/handler_test.go` (tests first): in `TestPageAndRouting`, add `.done { color: #1a7f37; }` (match the exact rule text used in the page) to the list of expected page substrings. Run `go test ./internal/web` and confirm it fails before the page change.
2. `internal/web/static/index.html`: add `.done { color: #1a7f37; }` next to the existing `.running` / `.waiting` rules (around line 12).
3. `README.md`: in the status table (line ~26) change the "Laundry done" color from "Default text color" to "Green". Check the rest of the README for any other mention of the done color and keep it consistent.
4. `ROADMAP.md`: already updated by the planner (Priority 1 table row and Priority 3); no further edit needed.
### Validation
- `go fmt ./...`, `go vet ./...`, `go test ./...`.
- Optional smoke: run the service, force a done state, and confirm the text renders green on a 360px viewport.
### Commit message
`feat(web): show "Laundry done" status text in green`
- Workflow limitation: `aide pr --dry-run` refuses the current `main` branch. Kept the current branch as instructed; no PR created.
- Verdict: PASS, approved for the per-task commit under `all_task`.
## Task: T-005
### Review Round 1 — 2026-10-12T13:24:25Z
- Findings: no required fixes or semantic concerns. The diff matches T-005: the done state uses `#1a7f37`, the page test requires that rule, and the README status table says Green. Status logic, messages, other colors, and README AI Workflow content are unchanged.
- Tests first: `go test ./internal/web` failed because the green `.done` rule was missing, then passed after the CSS change with the assertion retained.
- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, and `git diff --check` PASS.
- E2E: NOT_CONFIGURED as documented in README; optional browser smoke not run for this CSS-only change.
- Environment retry during implementation: sandbox blocked the Go build-cache write; approved rerun reached the expected failing test. No review retries or weakened assertions.
- Workflow limitation: `aide pr --dry-run` refuses the current `main` branch. Kept the current branch as instructed; no PR created.
- Verdict: PASS, approved for the per-task commit under `all_task`.
| T-004 | SSE push (`GET /api/events`) so the page refreshes on each valid MQTT message: sensor Hub, `Sampler.OnReceive`, SSE handler with keep-alive and write-deadline override, page EventSource with 60 s polling fallback, README/comment updates, tests first | done | Valid message -> `update` event to all clients; invalid -> none; immediate `update` on connect; keep-alive sent; stream outlives 10 s WriteTimeout; slow subscriber never blocks Receive; disconnects/shutdown clean up; `/api/status` unchanged; README documents endpoint and updated refresh behavior; fmt/vet/test (+race) pass | 2026-10-12T13:18:18Z: fmt/vet/test/race PASS; MQTT/SSE/Chrome smoke PASS; review PASS | none |
| T-005 | Show "Laundry done" status text in green (`.done { color: #1a7f37 }` in index.html), page test first, README status table update | done | Page contains the `.done` green rule (test fails before, passes after); text and status rule unchanged; README table says Green; fmt/vet/test pass | 2026-10-12T13:24:25Z: tests first, fmt/vet/test PASS; review PASS; E2E NOT_CONFIGURED | none |
@ -24,7 +24,7 @@ Objective: show the laundry status from the last 3 one-minute power samples of t
|---|---|---|
| fewer than 3 samples since backend start | "Waiting for data..." | grey |
| any of the last 3 samples is missing | "Incomplete data" | grey |
| all 3 samples under 3.00 W | "Laundry done" | default text color |
| all 3 samples under 3.00 W | "Laundry done" | green (see Priority 3) |
| otherwise (any sample 3.00 W or more) | "Laundry not yet done" | red |
- Page design:
@ -54,3 +54,18 @@ Objective: the page refreshes itself whenever a new MQTT power message arrives,
### Open
- None.
## Priority 3
Objective: show the "Laundry done" status text in green.
### Decided
- Only the color of the "Laundry done" status text changes, from default text color to green `#1a7f37` (readable on the white page background). Message text, status rule, JSON contract and other states' colors stay unchanged.
- The page already sets the status element's CSS class to the state name (`done`), so a `.done` rule is the whole page change.
- No dark mode: the page is white-background only by design.