feat: implement consumption history command - #12
Conversation
KristofferRisa
left a comment
There was a problem hiding this comment.
Thanks for this — the feature is well-built and tracks the spec in #9 closely: nullable *float64 handling, the home(id:) vs homes query split, dynamic period labels, and totals rows are all there as agreed.
I found three things I'd like fixed before merge, plus some scope questions. Everything is small.
I checked out the branch locally: go build, go vet, go test ./... and go mod verify all pass. Note that no CI has actually run on this PR (gh pr checks 12 reports no checks) — it may be pending first-time-contributor approval in the Actions tab. That matters because of #1.
Blockers
1. The lint gate will fail — 3 files aren't gofmt'd
.github/workflows/test.yml fails the build when gofmt -s -l . is non-empty. Currently:
internal/models/types.go # trailing blank line at EOF
internal/output/formatter_test.go # import block unsorted ("fmt" after "testing") + trailing whitespace
internal/output/pretty.go # 3 lines of trailing whitespace
gofmt -s -w . fixes all of it.
2. Period labels shift by a day for anyone outside the home's timezone
internal/output/formatter.go:33 — formatPeriod calls .Local() on from before formatting.
Tibber returns bucket boundaries carrying the home's offset (2023-10-01T00:00:00.000+02:00). Converting to the viewer's zone discards exactly the information that defines the bucket. Verified against that input:
TZ=Europe/Oslo -> "2023-10-01" correct
TZ=UTC -> "2023-09-30" wrong
TZ=America/Los_Angeles -> "2023-09-30" wrong
So a Norwegian home's October 1st renders as September 30th on a UTC server or any US machine — and the mislabeled row still carries October 1st's kWh. This affects DAILY, WEEKLY, MONTHLY and ANNUAL. Formatting from as-is preserves the correct bucket; .Local() is only really defensible for HOURLY.
The current tests can't catch this: TestFormatPeriod_DynamicResolution compares formatPeriod(...) against t1.Local().Format(...) — the same transformation on both sides, so it passes whether or not the bug exists, and it's fully vacuous under CI's TZ=UTC. Pinning an explicit time.FixedZone in the test would make it meaningful.
3. Unrelated GraphQL errors get masked as "home not found"
internal/api/client.go:180:
if strings.Contains(err.Error(), "does not exist") {
return nil, fmt.Errorf("home with ID %q not found", homeID)
}Substring-matching the API's prose is fragile, and the guard isn't scoped to the homeID != "" branch. Against a mock returning a schema error (Cannot query field "consumtion" — it does not exist on type Home):
no-homeID path -> home with ID "" not found (nonsensical)
with-homeID path -> home with ID "abc-123" not found (real cause discarded)
The original message is dropped entirely, so a typo'd query or an upstream schema change surfaces to the user as a bogus "home not found" — which is a rough debugging experience. Worth noting that the Home == nil check at line 203 already handles genuine home-not-found correctly, so this string match may just be redundant. At minimum, scope it to homeID != "" and wrap the original error rather than replacing it.
Should fix
4. --resolution hourly breaks the table
internal/output/pretty.go:268 — the period column is %-12s, but hourly labels are 20 chars, which pushes every subsequent column right:
2023-10-01 ████████████ 24.50 kWh ████████████ 120.40 NOK 4.90 NOK/kWh
01 Oct 00:00 - 00:00 ████████████ 24.50 kWh ████████████ 120.40 NOK 4.90 NOK/kWh
This is user story 2 from the spec (--resolution hourly --last 24) — the main drill-down case. Either widen the column or shorten the hourly label.
5. The PR description claims an EnergyResolution type that isn't in the diff
The
EnergyResolutioncustom type to properly represent resolution periods
There's no such Go type — EnergyResolution appears only as a GraphQL type name inside the two query string literals. Resolution is a bare string threaded through GetConsumptionHistory and FormatConsumptionHistory, validated by a switch in the command.
A real named type with Parse/String would actually be a nice improvement (it'd move validation out of consumption.go and let the formatters switch exhaustively) — but either implement it or drop it from the description.
6. Undisclosed dependency swap inside a "chore: lint" commit
Commit e4959b4 "chore: fix all linting errors in the branch" also migrates nhooyr.io/websocket v1.8.17 -> github.com/coder/websocket v1.8.15.
The migration itself is correct — coder/websocket is the maintained home of that package and 1.8.15 is its latest — but it's unrelated to consumption history, unmentioned in the PR body, and hidden under a lint label. I'd rather take it as its own PR (happy to merge it quickly), or at minimum call it out in the description.
7. Totals row leaks its bold escape past the newline
internal/output/pretty.go:357:
footerFmt := " %-12s %-26s %-26s %12s\n"
fmt.Fprintf(&sb, "%s"+footerFmt+"%s\n", Bold, "Totals", cons, cost, "", Reset)Reset lands after the \n, so bold is never closed on its own line and a spurious blank line follows. The %12s of "" also emits 12 trailing spaces under bold. Moving Reset inside footerFmt and dropping the empty column fixes both.
8. Empty result exits 1 — awkward for the jq use case
internal/commands/consumption.go:58 — a successful query returning zero nodes calls exitWithError. Spec user story 3 wants JSON piped into jq, so scripts now have to special-case exit 1 rather than just reading []. Consider emitting the empty array for --format json and reserving the error for pretty/markdown.
9. Markdown drops the currency when costs are null
internal/output/markdown.go:206 — currency is only captured inside if n.Cost != nil, so an all-null page renders | **Totals** | **0.00** | **0.00 ** | |. The pretty formatter captures it unconditionally at the top of the loop; worth making them consistent. Markdown's Avg Price column also has no unit, while pretty shows NOK/kWh.
Nits
internal/api/websocket.go:59— leftover comment:}() // (websocket.StatusNormalClosure, "")internal/api/queries.go:89—currentL3was reindented from spaces to a tab inside the live-measurement query string. Harmless to GraphQL, but it's unexplained noise in an unrelated query.docs/agents/domain.md(+51 lines) and the.gitignoreadditions (.agents/,.claude/skills/,skills-lock.json) are generic agent-tooling boilerplate referencing skills and ADR directories this repo doesn't have.CONTEXT.mdwas pre-agreed in #9; these weren't — I'd prefer to discuss them separately.internal/output/formatter.go— stdlib and third-party imports are in one block; the rest of the repo separates them.- Both formatters leave Avg Price blank in the totals row.
totalCost/totalConsumption(4.57 NOK/kWh in the sample) would be more useful than an empty cell. - Coverage gaps:
GetConsumptionHistoryis tested only on the happy path with ahomeID— no test for thehomesfallback, the not-found path, or the error masking in #3.HOURLYinformatPeriodis untested. Null-node rendering is tested at the API layer but in neither formatter. resolutionandlastare very generic package-level names incommands. No collision today (liveusesliveHomeID), but they're one future flag away from one.- The
config_test.goswitch tot.Setenvis a genuine improvement (auto-restores, no cross-test leakage). Usingt.Setenv(k, "")in place ofos.Unsetenvis safe here only becauseconfig.Loadtestsos.Getenv(...) != ""— just noting the coupling.
Blockers 1-3 are all small changes; everything under "Should fix" I'm happy to take as follow-ups if you'd rather land the feature first, with the exception of #4, which I think users will hit immediately. Nice work overall — the null handling and the query split are exactly right.
…eaking, empty results)
Four items from the review that were still open on the branch: - gofmt: internal/commands/consumption.go had a double blank line, which fails the CI format gate (`gofmt -s -l .`). Ran `gofmt -s -w .`. - TestFormatPeriod_DynamicResolution asserted against `t1.Local()` after the production code correctly stopped calling `.Local()`, so it passed only on UTC runners and failed from UTC+11 east. Rewritten to pin time.Local to a fixed western zone and assert exact labels against offset-carrying inputs, so it is timezone-independent and now actually fails if `.Local()` is reintroduced. Adds the previously untested HOURLY and fallback cases. - JSON output marshalled a nil slice as `null`, so an empty result broke `--format json | jq '.[]'`. Emit `[]` instead. - Markdown captured the currency only inside the `n.Cost != nil` branch, so an all-null page rendered `**0.00 **`. Hoisted to the top of the loop, matching the pretty formatter. Verified: gofmt -s, go vet, go mod verify, go test -race, and the binary smoke test all pass; internal/output tests pass under UTC, Europe/Oslo, Pacific/Auckland, America/Los_Angeles and Asia/Kolkata.
|
I think the PR should be ready now, lmk if there is anything else you want me to change |
docs/agents/domain.md described a repo layout this project doesn't have: docs/adr/, CONTEXT-MAP.md, src/<context>/docs/adr/, and skills (/domain-modeling, /grill-with-docs, /improve-codebase-architecture) that aren't part of this repo. Its examples were about event-sourced orders and a Postgres write model. It documented a tooling convention rather than this codebase, so it's removed. The CLAUDE.md block existed only to point at that file. Replaced with a pointer to CONTEXT.md, which stays: it was agreed in KristofferRisa#9 and defines the Tibber vocabulary this code actually uses. .gitignore's agent entries are left alone — they make no claim about the repo layout and cost nothing.
This PR implements the consumption history feature as specified in #9.
It adds:
powerctl consumptioncommand to display historical data.Note: This PR also updates the websocket dependency from
nhooyr.io/websockettogithub.com/coder/websocketto resolve lint deprecation warnings across the codebase.Closes #9