From e473850aacf9c198f8eabc09baeeb1d18e79b694 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 22:06:42 +1000 Subject: [PATCH] fix(ui): stop the pages being cached, and the labels duplicating Firefox served a cached copy of the brewing page after the advice became a button and a pop-out, so the phone kept showing the old inline advice - a long paragraph in the table cell, with the button label reading "Read the adviceAdvice". Two separate causes: The pages had no cache headers, so Firefox was free to reuse them. They now send Cache-Control: no-store, must-revalidate. The rule that hides the short label was scoped to `table.data .short`, so it did not apply to the advice button's label. A stale stylesheet then showed both forms at once. The rule is now on `.short` alone, so it applies wherever the pattern is used - a table heading, the advice button, the Active Beers page - and only ever one form can show. Verified at 390px and 1280px: the phone shows "Advice", the desktop shows "Read the advice" and "BREWING ADVICE", and no element ever shows both. 192 tests pass. --- src/flask/app_factory.py | 12 ++++++++++++ src/flask/static/css/app.css | 37 ++++++++++++++++-------------------- tests/test_app.py | 12 ++++++++++++ tests/test_brew_advice.py | 30 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/flask/app_factory.py b/src/flask/app_factory.py index 5655d8e..348dbd6 100644 --- a/src/flask/app_factory.py +++ b/src/flask/app_factory.py @@ -87,6 +87,18 @@ def create_app(config: dict | None = None) -> Flask: if config: app.config.update(config) + # The pages must not be cached by the browser. + + # A phone held on to a cached copy of the brewing page and showed + # the old inline advice instead of the button and the pop-out, so + # the change was not visible until the cache was cleared by hand. + @app.after_request + def _no_store(response): + if response.mimetype == "text/html": + response.headers["Cache-Control"] = "no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + return response + csrf = CSRFProtect(app) # ------------------------------------------------------------------ # diff --git a/src/flask/static/css/app.css b/src/flask/static/css/app.css index b419c99..11dd675 100644 --- a/src/flask/static/css/app.css +++ b/src/flask/static/css/app.css @@ -484,9 +484,14 @@ table.data .num { font-family: var(--mono); text-align: right; white-space: nowr table.data .comment { color: var(--text-muted); font-size: .84rem; } /* A heading that is too long for a phone column. The long form shows - on a desktop and the short form on a phone - see the media query. */ -table.data .short { display: none; } -table.data .advice-trigger .short { display: none; } + on a desktop and the short form on a phone - see the media query. + + The rule is on `.short` alone, not on `table.data .short`, so it + applies everywhere the pattern is used - in a table heading, in the + advice button, and on the Active Beers page. When only the table + was covered, the advice button could show both forms at once and + read "Read the adviceAdvice". */ +.short { display: none; } /* On a narrow screen the header wraps onto two rows: the brand and the sign-out on the first, the nav tabs on the second where they get the @@ -570,19 +575,17 @@ table.data .advice-trigger .short { display: none; } overflow-wrap: break-word; } - /* The heading gets a short form, because a heading that is cut off - is worse than a heading that is brief. */ - table.data .col-abv .long, - table.data .col-advice .long, - table.data .col-finished .long, - table.data .col-comment .long { + /* The long form gives way to the short form on a phone, because a + heading that is cut off is worse than a heading that is brief. + + The rule is on the span itself rather than on the column, so it + applies everywhere the pattern is used - in a table heading, in + the advice button, and on the Active Beers page. */ + .long { display: none; } - table.data .col-abv .short, - table.data .col-advice .short, - table.data .col-finished .short, - table.data .col-comment .short { + .short { display: inline; } @@ -594,14 +597,6 @@ table.data .advice-trigger .short { display: none; } gap: .2rem; } - table.data .advice-trigger .long { - display: none; - } - - table.data .advice-trigger .short { - display: inline; - } - /* The action column holds a button. */ table.data .col-action { white-space: nowrap; diff --git a/tests/test_app.py b/tests/test_app.py index 091f020..7ec8095 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -714,3 +714,15 @@ def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo): assert len(entry["curve"]) == 2 assert entry["curve"][0]["progress"] == 0.0 assert entry["curve"][1]["progress"] == 1.0 + + +def test_the_pages_are_not_cached(client): + """The HTML pages must not be cached by the browser. + + A phone held on to a cached copy of the brewing page and kept + showing the old inline advice after the button and the pop-out had + been deployed, so the change was invisible until the cache was + cleared by hand. + """ + response = client.get("/login") + assert "no-store" in response.headers.get("Cache-Control", "") diff --git a/tests/test_brew_advice.py b/tests/test_brew_advice.py index a9c438b..6144911 100644 --- a/tests/test_brew_advice.py +++ b/tests/test_brew_advice.py @@ -1049,6 +1049,36 @@ def test_the_tables_keep_their_columns_on_a_phone(client, repo): assert "Cider raspberry elderberry" in body +def test_the_long_and_short_labels_are_both_in_the_page(client, repo): + """The long and the short label are both in the markup. + + CSS picks which one shows - the long form on a desktop, the short + form on a phone. If only one is in the markup the other cannot be + shown at all, and if the CSS rule misses one of them the page shows + "Read the adviceAdvice". + + This is a markup assertion, not a CSS assertion: it makes sure + both forms exist for the stylesheet to choose between. + """ + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + client.post("/login", data={"username": "beer", "password": "beer_pass"}, + follow_redirects=True) + + seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")]) + doc = repo.readings.find_one({"beer_run_id": "1"}) + repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True) + + body = client.get("/brews").get_data(as_text=True) + + # The heading has both forms. + assert 'Brewing advice' in body + assert 'Advice' in body + + # The button has both forms. + assert 'Read the advice' in body + assert 'Advice' in body + + def test_a_failed_advice_is_marked_as_such(client, repo): """A generation failure reads differently from real advice.""" repo.users.insert_one({"username": "beer", "password": "beer_pass"})