Browse Source

feat(web): show "Laundry done" status text in green

main
Domagoj Zecevic 3 weeks ago
parent
commit
2e8d9e8570
  1. 27
      .ai/HANDOFF.md
  2. 66
      .ai/PLAN.md
  3. 12
      .ai/REVIEW.md
  4. 1
      .ai/TASKS.md
  5. 2
      README.md
  6. 17
      ROADMAP.md
  7. 2
      internal/web/handler_test.go
  8. 1
      internal/web/static/index.html

27
.ai/HANDOFF.md

@ -87,3 +87,30 @@ Append-only role handoff log. Each role adds one entry when its step is complete
- Status: done. Commit message read from the T-004 next_task entry. - Status: done. Commit message read from the T-004 next_task entry.
- All four board tasks are done; T-004 is the only task completed in this invocation and receives its own Conventional Commit. - All four board tasks are done; T-004 is the only task completed in this invocation and receives its own Conventional Commit.
### T-005 — plan — 2026-10-12T13:40:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Planned a CSS-only change to show the "Laundry done" status text in green (#1a7f37), with a page test and README table update |
| Files Changed | .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md, ROADMAP.md |
| Next Role | implement |
### T-005 — next_task (dev/all_task) — 2026-10-12T13:24:25Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Made the Laundry done status green (#1a7f37), added the page assertion before implementation, and updated the README status table |
| Validation | fmt/vet/test PASS; E2E NOT_CONFIGURED; review PASS |
| Commit | feat(web): show "Laundry done" status text in green |
| Next Role | dev commit |
- Includes the existing T-005 roadmap and planning artifacts.
- PR limitation: `aide pr --dry-run` refuses main; repository instructions require staying on the current branch.
### T-005 — commit_task (all_task) — 2026-10-12T13:24:25Z
- Status: done. Commit message read from the T-005 next_task entry.
- All five board tasks are done; T-005 is the only task completed in this invocation and receives its own Conventional Commit.

66
.ai/PLAN.md

@ -2,51 +2,25 @@
Status: **ready_for_implement** 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. 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 ## Scope
- Standard library only; no new dependencies. - CSS-only change in the page plus docs and a test. No backend, JSON contract, status rule or message text changes.
- Out of scope: live-reading line, WebSocket, changes to the status rule, JSON contract of `/api/status`, or 1-minute sampling. - Out of scope: dark mode, other state colors, layout changes.
## Design decisions (from ROADMAP.md Priority 2) ## T-005 — Green "Laundry done" text
- 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): 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`).
- `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). ### Files to change
- `cancel` removes the subscriber; safe to call more than once. 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.
- `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`. 2. `internal/web/static/index.html`: add `.done { color: #1a7f37; }` next to the existing `.running` / `.waiting` rules (around line 12).
- Endpoint `GET /api/events` (`text/event-stream`, `Cache-Control: no-store`): 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.
- On connect: flush headers and send an initial `event: update` so the client syncs immediately (covers reconnects). 4. `ROADMAP.md`: already updated by the planner (Priority 1 table row and Priority 3); no further edit needed.
- 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). ### Validation
- 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. - `go fmt ./...`, `go vet ./...`, `go test ./...`.
- Exit on `r.Context().Done()` (client gone or server shutdown) and call `cancel`. - Optional smoke: run the service, force a done state, and confirm the text renders green on a 360px viewport.
- 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). ### Commit message
- Page (`index.html`): `feat(web): show "Laundry done" status text in green`
- 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`

12
.ai/REVIEW.md

@ -54,3 +54,15 @@
- Environment retries: Go formatting/testing needed writable build-cache access; staging needed writable Git-index access. Approved reruns passed. - Environment retries: Go formatting/testing needed writable build-cache access; staging needed writable Git-index access. Approved reruns passed.
- Workflow limitation: `aide pr --dry-run` refuses the current `main` branch. Kept the current branch as instructed; no PR created. - 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`. - 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`.

1
.ai/TASKS.md

@ -24,3 +24,4 @@ Command expectations:
| T-002 | Per-minute sampler, MQTT subscription, HTTP `/api/status` and phone-first page, `main.go` env config | done | Sampler records valid/missing samples per tick; `/api/status` matches JSON contract; page has white background, large status, `47.70W` readings, viewport meta, 60 s refresh; fmt/vet/test pass | 2026-10-12T11:17:06Z: fmt/vet/test/race PASS; MQTT and 360px Chrome smoke PASS | none | | T-002 | Per-minute sampler, MQTT subscription, HTTP `/api/status` and phone-first page, `main.go` env config | done | Sampler records valid/missing samples per tick; `/api/status` matches JSON contract; page has white background, large status, `47.70W` readings, viewport meta, 60 s refresh; fmt/vet/test pass | 2026-10-12T11:17:06Z: fmt/vet/test/race PASS; MQTT and 360px Chrome smoke PASS | none |
| T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | done | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | 2026-10-12T11:22:18Z: fmt/vet/test PASS; Docker build/runtime PASS; README workflow preserved | none | | T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | done | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | 2026-10-12T11:22:18Z: fmt/vet/test PASS; Docker build/runtime PASS; README workflow preserved | none |
| 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-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 |

2
README.md

@ -23,7 +23,7 @@ Rules apply in this order:
| Fewer than three samples recorded since startup | Waiting for data... | Grey | | Fewer than three samples recorded since startup | Waiting for data... | Grey |
| Any of the last three samples is missing | Incomplete data | Grey | | Any of the last three samples is missing | Incomplete data | Grey |
| Any sample is at least 3.00 W | Laundry not yet done | Red | | Any sample is at least 3.00 W | Laundry not yet done | Red |
| All three samples are below 3.00 W | Laundry done | Default text color | | All three samples are below 3.00 W | Laundry done | Green |
Exactly **3.00 W is active**; `2.99 W` and `0 W` are idle. Malformed JSON and Exactly **3.00 W is active**; `2.99 W` and `0 W` are idle. Malformed JSON and
payloads without numeric `ENERGY.Power` are logged and ignored; they do not replace payloads without numeric `ENERGY.Power` are logged and ignored; they do not replace

17
ROADMAP.md

@ -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 | | fewer than 3 samples since backend start | "Waiting for data..." | grey |
| any of the last 3 samples is missing | "Incomplete 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 | | otherwise (any sample 3.00 W or more) | "Laundry not yet done" | red |
- Page design: - Page design:
@ -54,3 +54,18 @@ Objective: the page refreshes itself whenever a new MQTT power message arrives,
### Open ### Open
- None. - 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.
- Docs: README status table is updated.
### Open
- None.

2
internal/web/handler_test.go

@ -62,7 +62,7 @@ func TestPageAndRouting(t *testing.T) {
if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") {
t.Fatalf("page response: %+v", page.Result()) t.Fatalf("page response: %+v", page.Result())
} }
for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "EventSource", "/api/events", "toFixed(2)", "--", "clamp("} { for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "EventSource", "/api/events", "toFixed(2)", "--", "clamp(", ".done { color: #1a7f37; }"} {
if !strings.Contains(page.Body.String(), text) { if !strings.Contains(page.Body.String(), text) {
t.Errorf("page missing %q", text) t.Errorf("page missing %q", text)
} }

1
internal/web/static/index.html

@ -10,6 +10,7 @@
main { max-width: 44rem; margin: 0 auto; padding: 3rem 1.25rem; text-align: center; } main { max-width: 44rem; margin: 0 auto; padding: 3rem 1.25rem; text-align: center; }
h1 { margin: 1rem 0 2rem; font-size: clamp(3rem, 12vw, 5rem); line-height: 1.1; overflow-wrap: anywhere; } h1 { margin: 1rem 0 2rem; font-size: clamp(3rem, 12vw, 5rem); line-height: 1.1; overflow-wrap: anywhere; }
.running { color: #b91c1c; } .running { color: #b91c1c; }
.done { color: #1a7f37; }
.waiting, .incomplete, .unavailable { color: #666; } .waiting, .incomplete, .unavailable { color: #666; }
.label { color: #666; font-size: 1rem; } .label { color: #666; font-size: 1rem; }
#readings { display: flex; flex-wrap: wrap; justify-content: center; gap: 1.25rem; padding: 0; list-style: none; color: #000; font-size: 1rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } #readings { display: flex; flex-wrap: wrap; justify-content: center; gap: 1.25rem; padding: 0; list-style: none; color: #000; font-size: 1rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }

Loading…
Cancel
Save