From 2e8d9e8570c8eceb1886e8149358d8a971ab5b76 Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Mon, 12 Oct 2026 15:26:27 +0200 Subject: [PATCH] feat(web): show "Laundry done" status text in green --- .ai/HANDOFF.md | 27 ++++++++++++++ .ai/PLAN.md | 66 +++++++++++----------------------- .ai/REVIEW.md | 12 +++++++ .ai/TASKS.md | 1 + README.md | 2 +- ROADMAP.md | 17 ++++++++- internal/web/handler_test.go | 2 +- internal/web/static/index.html | 1 + 8 files changed, 79 insertions(+), 49 deletions(-) diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index f558cf8..7d33b90 100644 --- a/.ai/HANDOFF.md +++ b/.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. - 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. diff --git a/.ai/PLAN.md b/.ai/PLAN.md index 17accde..5b6fcae 100644 --- a/.ai/PLAN.md +++ b/.ai/PLAN.md @@ -2,51 +2,25 @@ 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 -- 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` +- 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` diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index 6de460e..b67deff 100644 --- a/.ai/REVIEW.md +++ b/.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. - 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`. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 77bfa43..a28ce7b 100644 --- a/.ai/TASKS.md +++ b/.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-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-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 | diff --git a/README.md b/README.md index 183b9e7..4941487 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Rules apply in this order: | Fewer than three samples recorded since startup | Waiting for 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 | -| 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 payloads without numeric `ENERGY.Power` are logged and ignored; they do not replace diff --git a/ROADMAP.md b/ROADMAP.md index 6dcc657..964342e 100644 --- a/ROADMAP.md +++ b/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 | | 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. +- Docs: README status table is updated. + +### Open + +- None. diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index 6eaa8ba..03c69bf 100644 --- a/internal/web/handler_test.go +++ b/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") { 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) { t.Errorf("page missing %q", text) } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 10248b4..2462ae0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -10,6 +10,7 @@ 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; } .running { color: #b91c1c; } + .done { color: #1a7f37; } .waiting, .incomplete, .unavailable { color: #666; } .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; }