Skip to content

Rebuild the dashboard around drink-window filters - #22

Merged
GuvHas merged 4 commits into
mainfrom
feature/interactive-dashboard-ux
Sep 2, 2026
Merged

GuvHas merged 4 commits into
mainfrom
feature/interactive-dashboard-ux

Conversation

@GuvHas

@GuvHas GuvHas commented Sep 2, 2026

Copy link
Copy Markdown
Owner

The page was a sortable table with a search box. On a phone, with 151 bottles, that is a very long scroll and no way to ask the only question worth asking: what can I drink tonight? The integration has counted ready_to_drink and past_drink_window since 0.0.18, but the dashboard had no way to act on either.

431 tests (72 added), ruff clean, mypy clean, and checked by hand in Chromium.

One correction to the brief

The filter chips were not "missing or not rendering" — they had never existed. git log on cellar.html shows a search box and a table from the first commit. That is worth being exact about, because "restore" and "build" are different jobs: there was no regression to find, so this is new work and it needed new tests rather than a fix to old ones.

UX critique, and what I did about it

Problem What was wrong Change
No way to narrow by intent The only question a cellar app answers is "what's drinkable", and the table could not express it Four chips with live counts: All wines, Ready to drink, Past window, Needs aging
Controls scroll away Search sat at the top of a 151-row page; by row 20 it is gone Search, chips and sort in a sticky header
Two numbers instead of a state FROM 2027 TO 2032 makes the reader do the arithmetic against the current year, per row A bar showing where this year sits in the window, plus a pill saying what to do
Colour was the only signal Red/green alone fails for the ~8% of men with colour-vision deficiency, and in bright sun on a phone Colour and a text label, always both
Tap targets too small 12px table cells; nothing was tappable except the wine link 48px minimum on everything interactive, matching Home Assistant's own rows
Columns hidden on mobile Loc and Bin had .hide-mobile, so the phone — the device you hold at the rack — was the one that could not see the bin Bin and location on the summary line; barcode one tap away
Search was nearly useless One substring test over name+location+bin. Vintage was not searchable at all, and "bindi 2023" matched nothing Five fields, every token must match
Nothing to act on The row was inert apart from an external link Drawer with Copy bin and Open in CellarTracker

Typography and density. Wine names are the scanning target, so they get the weight; everything else steps down to 13px secondary. Prices and years use tabular-nums so columns of digits line up. On desktop the card collapses to one dense line — name, meta, window, price — because a mouse and 1000px want more rows per screen, not bigger ones.

The change that is behavioural, not cosmetic

The last year of a drinking window now reads "Drink this year" instead of red. The page had painted it red since the beginning, which reads as expired; the coordinator has always counted it as ready. They disagreed, and the repo knew — test_drink_window.py documents it as "one deliberate divergence".

They agree now, and test_dashboard_agrees_with_sensors.py keeps them agreeing: it runs the Python coordinator and the page's JavaScript over the same awkward cellar (inverted windows, single bounds, blanks, both boundary years) and compares the counts. A chip contradicting the sensor card beside it is the integration contradicting itself, and the user has no way to tell which number is wrong.

Phase 1 — tests first

File Covers
tests/dashboard_js.py Harness: runs the page's real script under node with a fake DOM
tests/test_dashboard_filters.py 53 specs — window states, progress, search, chips, sorting
tests/test_dashboard_agrees_with_sensors.py 5 — the cross-check above
tests/test_dashboard_render.py 13 — cards, pills, bar widths, drawer, state across refresh

All red before the rewrite. Edge cases the brief asked for are covered explicitly: missing bounds, blank strings, null, "N/A", negative years, inverted windows, a single-year window (which would otherwise divide by zero), rows missing the sort key, and ties.

No Jest or Vitest. The page has no build step, deliberately — it is served straight out of the integration directory into the Companion app. Adding npm to a HACS integration repo would cost more than it returns, and the repo already had a working pattern: extract the <script>, run it under node. I extended that. The page's decision logic is plain top-level functions with no DOM access, which is what makes them callable directly; a comment in cellar.html says so, since it is a testability constraint rather than an accident.

Phase 2 — implementation

Vanilla JS, one file, no dependencies, one request. textContent throughout — no innerHTML touches server data. One DocumentFragment per render, so a 1,000-bottle cellar reflows once.

The page grows 16 KB → 31 KB (~7 KB gzipped). Worth stating plainly; it is still negligible beside the inventory payload, and still one request.

Barcode joins COMPACT_FIELDS. A field the page searches but the API never sends is dead code that tests can still satisfy, so test_every_field_the_page_searches_is_actually_served now reads SEARCH_FIELDS out of the page and checks it against the projection.

Two things I did not build, and why

Notes in the drawer. The brief asked for them. They are free-form prose, potentially long, and PR #18 deliberately kept tasting and cellar notes out of diagnostics for exactly that reason. Shipping every note to every dashboard load is a payload and privacy decision that deserves its own PR; the drawer links to CellarTracker, where the notes live.

"Highlight in Rack" / ESPHome LED. The plumbing is easy — the page already holds a session that can POST /api/services/.... What is missing is which service: nothing in the integration knows the entity, and inventing one produces a button that fails. Doing this properly means an options-flow field naming the service, then a button rendered only when it is set. Happy to do that as a follow-up; it is a config-surface change, not a dashboard change. Copy bin ships now because it needs no configuration.

Interactive verification

The automated tests use a fake DOM, which proves structure and text but not layout. I checked layout in Chromium:

git checkout feature/interactive-dashboard-ux
python -m pytest -q            # 431 passed
ruff check .                   # clean (0.15.8, as CI pins)

To drive it yourself against a synthetic 151-bottle cellar, serve cellar.html from any static server alongside stub JSON at /api/cellartracker/inventory and /api/cellartracker/settings, then open it at 412px wide. Or install the branch in Home Assistant and open /cellartracker/cellar.html.

What I confirmed by hand, at 412px and 1000px, in both colour schemes:

  • chip counts 151 / 76 / 15 / 45, and each chip renders exactly that many rows
  • chip + search compose — "chianti 2023" inside All wines gives 15
  • the drawer opens with location, bin, barcode, window and both actions
  • sort by value descending puts the Penfolds Grange on top
  • empty search shows "No wines match this filter." rather than a blank page
  • no console errors

That pass is also what caught the desktop grid placing the wine name in the middle of the row with a hole beside it — the tests were green while it was broken, because a fake DOM has no layout.


🤖 Generated with Claude Code

https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4


Generated by Claude Code

The page was a sortable table with a search box. On a phone, with 151
bottles, that is a very long scroll and no way to ask the only question
worth asking: what can I drink tonight? The integration has counted
ready_to_drink and past_drink_window since 0.0.18, but the dashboard had
no way to act on either.

  * Four filter chips - All wines, Ready to drink, Past window, Needs
    aging - each carrying its count, in a sticky header alongside the
    search box so neither scrolls away.
  * Rows became expandable cards. The summary carries the name, vintage,
    location, bin, value and a drink-window bar; tapping opens a drawer
    with the barcode, the full window, a Copy bin action and a link to
    CellarTracker.
  * The window is a position now, not two numbers: a bar showing where
    this year sits between BeginConsume and EndConsume, and a pill saying
    what to do about it.
  * Sort by wine, vintage, value, drink-by, bin or location, either way.

The last year of a window now reads "Drink this year" rather than red.
The page had painted it red since the beginning, which reads as expired;
the coordinator has always counted it as ready. They agree now, and
test_dashboard_agrees_with_sensors runs both implementations over one
cellar to keep them agreeing - a chip contradicting the sensor card
beside it is the integration contradicting itself.

Search covers wine, vintage, location, bin and barcode, and every token
has to match, so "bindi 2023" narrows. Previously it was one substring
test over three fields and a vintage could not be searched at all.
Barcode joins the compact projection to make that real: a field the page
searches but the API never sends is dead code that tests can still
satisfy, so test_every_field_the_page_searches_is_actually_served now
reads SEARCH_FIELDS out of the page and checks it against COMPACT_FIELDS.

Written test-first: 72 new tests over the page's own script under node -
window states, search, sorting, rendering and the drawer. The decision
logic is pure functions at the top level of the script, which is what
makes it callable without a build step; a comment in the page says so.
The fake DOM flattens a document fragment the way a browser does, after
an earlier version nested it and would have passed on a shape no browser
produces.

Also checked by hand in Chromium at 412px and 1000px, light and dark,
which is how the desktop grid turned out to be placing the name in the
middle of the row.

The page grows from 16 KB to 31 KB, about 7 KB gzipped. Still one file,
still no dependencies, still one request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9ee95d967

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread custom_components/cellar_tracker/www/cellar.html
Comment thread custom_components/cellar_tracker/www/cellar.html Outdated
Both reported by Codex on #22, both confirmed before fixing.

normaliseWine collapses a missing year to 0, and the missing-value rule
only recognised null and "". So 0 sorted as a number smaller than every
real year, and sorting by vintage ascending led with every NV bottle in
the cellar - the exact thing that rule exists to prevent. Zero now counts
as absent for Vintage, BeginConsume and EndConsume, where there is no
such year. Valuation is deliberately left alone: a bottle really can be
worth nothing recorded, and cheapest-first should still show it.

The copy button reported success unconditionally. `navigator.clipboard`
requires a secure context, and a great many Home Assistant installs are
reached over plain http on a LAN address - so `written` was undefined,
the else branch ran, and the button said "Copied" having copied nothing.
That is worse than doing nothing: the user walks to the rack and pastes
whatever was there before. There is a real execCommand fallback now, and
the label follows what actually happened.

Finding the second one meant fixing the harness first. It set
`globalThis.navigator`, which node 21+ ignores in silence because it
ships its own getter-only navigator - so every clipboard fixture was
testing the same world and the "missing" case looked like it passed. It
uses defineProperty now and throws if the stub does not take.

The copy tests assert what reached the clipboard, not just what the
button says, so "said Copied" cannot pass for "copied something".

441 tests, ruff clean, mypy clean. Re-checked in Chromium: vintage
ascending now ends with the NV bottles, and Copy bin really does put the
bin on the clipboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4
The README described the page that existed before this branch, and eight
things in it had gone stale - some since 0.0.18:

  * "searchable, sortable table" - it is a filtered list now
  * the compact view was said to carry nine columns; Barcode made it ten
  * "drink-window colouring (green = ready, red = too early or past)" -
    too-early is blue and the final year is amber
  * the drink-window sensors were missing from the feature list entirely
  * the summary card example labelled the diagnostic entity "Connection",
    which stopped being true in 0.0.18 when it began reporting a timestamp
  * "About drink-window cards" said to sort by the FROM/TO columns, which
    no longer exist - and the honest answer is now much better, since the
    chips and the sensors both do it
  * the test count said 149
  * nothing told a contributor that mypy needs Home Assistant installed,
    which is the difference between a real check and a silent pass

Two of those are pinned by tests now: the README's column count is read
against COMPACT_FIELDS, and its state labels are read out of cellar.html
itself, so renaming one there fails until the README follows. Both were
confirmed to fail against a sabotaged README rather than assumed to work.

0.0.20 carries the dashboard rebuild and the coordinator's hass cleanup.
The notes lead with the one visible change: a wine in the last year of
its window now reads "Drink this year" in amber rather than red. Red said
"too late" while the sensors counted it as ready, so the page and the
sensors disagreed about the same bottle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4
@GuvHas
GuvHas merged commit 85a9711 into main Sep 2, 2026
12 checks passed
@GuvHas
GuvHas deleted the feature/interactive-dashboard-ux branch September 3, 2026 12:27
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.

2 participants