Skip to content

feat: implement consumption history command - #12

Merged
KristofferRisa merged 18 commits into
KristofferRisa:mainfrom
pcriv:feat-consumption-history
Aug 25, 2026
Merged

feat: implement consumption history command#12
KristofferRisa merged 18 commits into
KristofferRisa:mainfrom
pcriv:feat-consumption-history

Conversation

@pcriv

@pcriv pcriv commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This PR implements the consumption history feature as specified in #9.

It adds:

  • The powerctl consumption command to display historical data.
  • Proper formatting across pretty, JSON, and markdown output formats, with dynamically adjusted period representations.
  • Necessary queries in the API client and robust error handling.
  • Full unit test coverage for the output formatters, including dynamic time handling.

Note: This PR also updates the websocket dependency from nhooyr.io/websocket to github.com/coder/websocket to resolve lint deprecation warnings across the codebase.

Closes #9

@pcriv
pcriv requested a review from KristofferRisa as a code owner August 24, 2026 22:45

@KristofferRisa KristofferRisa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:33formatPeriod 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 EnergyResolution custom 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:206currency 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:89currentL3 was 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 .gitignore additions (.agents/, .claude/skills/, skills-lock.json) are generic agent-tooling boilerplate referencing skills and ADR directories this repo doesn't have. CONTEXT.md was 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: GetConsumptionHistory is tested only on the happy path with a homeID — no test for the homes fallback, the not-found path, or the error masking in #3. HOURLY in formatPeriod is untested. Null-node rendering is tested at the API layer but in neither formatter.
  • resolution and last are very generic package-level names in commands. No collision today (live uses liveHomeID), but they're one future flag away from one.
  • The config_test.go switch to t.Setenv is a genuine improvement (auto-restores, no cross-test leakage). Using t.Setenv(k, "") in place of os.Unsetenv is safe here only because config.Load tests os.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.

pcriv and others added 5 commits August 25, 2026 11:21
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.
@pcriv

pcriv commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

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.
@KristofferRisa
KristofferRisa merged commit c6a8030 into KristofferRisa:main Aug 25, 2026
5 checks passed
@pcriv
pcriv deleted the feat-consumption-history branch August 25, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spec: Consumption History Command

2 participants