From 27b2611a206cf19d3389f13ae8bc119695e5a3c0 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 21:48:07 +1000 Subject: [PATCH] fix(ui): keep the table columns readable on a phone The tables were squeezed to fit the screen, and with seven to nine columns on a 390px phone every heading and every value was truncated to a few characters - a gravity of "1.0..." and a date of "202..." are worse than useless, and the beer name was painted over the gravity reading because the cell was allowed to overflow. The tables keep their natural width and scroll sideways inside their card, which is what a table does. The advice is a small button so the column that matters is narrow, and the headings have a short form for a phone ("Brewing advice" -> "Advice", "Attenuation" -> "Att."). The secondary columns - the drop, the legacy ABV and the days - go away on a phone; the details are still on the run's own page. A name and a comment get a sensible minimum width and wrap on a word boundary rather than one word per line. The desktop is untouched: the same full headings, the same 806px table, and the pop-out still opens on hover. --- src/flask/static/css/app.css | 184 ++++++++++++++++-------------- src/flask/static/js/app.js | 24 ---- src/flask/templates/analysis.html | 52 ++++----- src/flask/templates/brews.html | 68 +++++------ src/flask/templates/history.html | 88 +++++++------- tests/test_brew_advice.py | 25 ++++ 6 files changed, 232 insertions(+), 209 deletions(-) diff --git a/src/flask/static/css/app.css b/src/flask/static/css/app.css index fb6acd1..b419c99 100644 --- a/src/flask/static/css/app.css +++ b/src/flask/static/css/app.css @@ -451,7 +451,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } /* ---------------------------------------------------------------- tables */ -.table-wrap { overflow-x: auto; } +.table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} table.data { width: 100%; @@ -480,21 +483,18 @@ table.data tbody tr:hover { background: var(--bg-raised-2); } table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; } table.data .comment { color: var(--text-muted); font-size: .84rem; } -/* On a narrow screen the tables reflow into stacked cards. +/* 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; } - A seven-column table does not fit a phone. It was left to scroll - sideways inside its wrapper, which meant the columns on the right were - off the screen and the brewer had to pan the table to see them. +/* 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 + full width. - Each row becomes a card: the header cell becomes a label above its - value, and the cells stack. */ + They used to sit on one row with the brand and the sign-out, and the + last tab was cut off mid-word. */ @media (max-width: 720px) { - /* The header wraps onto two rows on a phone: the brand and the - sign-out on the first, the nav tabs on the second where they get - the full width and scroll sideways if they still do not fit. - - They used to sit on one row with the brand and the sign-out, and - the last tab was cut off mid-word. */ .topbar__inner { flex-wrap: wrap; row-gap: .5rem; @@ -537,79 +537,97 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; } letter-spacing: .01em; } + /* The tables stay tables on a phone. + + They scroll sideways inside their card, which is what a table + does. They used to be squeezed to fit instead, and with seven + to nine columns on a 390px screen that meant every heading and + every value was truncated to a few characters - a gravity of + "1.0..." and a date of "202..." are worse than useless. + + So the columns keep their natural width, the table scrolls, and + the advice is a small button so the column that matters is + narrow. */ + table.data { + table-layout: auto; + width: 100%; + } + + table.data th, + table.data td { + padding-left: .5rem; + padding-right: .5rem; + } + + /* A name and a comment are words, so they get a sensible minimum + width and wrap on a word boundary - not one word per line. */ + table.data td.col-name, + table.data td.col-comment { + min-width: 7rem; + max-width: 12rem; + white-space: normal; + word-break: normal; + 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 { + display: none; + } + + table.data .col-abv .short, + table.data .col-advice .short, + table.data .col-finished .short, + table.data .col-comment .short { + display: inline; + } + + /* The advice trigger is small on a phone - it is a button, and + the advice itself is in the pop-out. */ + table.data .advice-trigger { + font-size: .68rem; + padding: .25rem .4rem; + 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; + } + + table.data .col-action .btn { + padding-left: .5rem; + padding-right: .5rem; + font-size: .72rem; + } + + /* The secondary columns go away on a phone - the details are still + on the run's own page. */ + table.data .col-drop, + table.data .col-legacy, + table.data .col-days { + display: none; + } + + /* The scroll area keeps a visible scrollbar on a phone, so it is + obvious there is more of the table to the right. */ .table-wrap { - overflow-x: visible; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; } - table.data, - table.data tbody, - table.data tr, - table.data td { - display: block; - width: auto; - } - - table.data thead { - /* HIDDEN in the stacked layout. Each cell shows its column - name as a label, so the header row would be a duplicate - and - it was 230px of the first card. */ - display: none; - } - - table.data tr { - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: .75rem; - padding: .25rem .1rem; - background: var(--bg-raised-2); - } - - table.data tbody tr:hover { - background: var(--bg-raised-2); - } - - table.data th { - display: block; - border-bottom: 1px solid var(--border); - } - - table.data td { - display: grid; - grid-template-columns: 8.5rem 1fr; - gap: .5rem; - align-items: baseline; - padding: .45rem .7rem; - border-bottom: 1px solid rgba(255, 255, 255, .04); - text-align: left; - } - - table.data td::before { - /* The column heading, shown as the row's label. */ - content: attr(data-label); - font-size: .65rem; - font-weight: 650; - letter-spacing: .07em; - text-transform: uppercase; - color: var(--text-faint); - } - - table.data td.num { - text-align: left; - } - - table.data td.right, - table.data td:empty { - grid-template-columns: 1fr; - } - - table.data td.right::before { - content: none; - } - - /* A cell with nothing in it must not leave a gap. */ - table.data td:empty { - display: none; - } } /* ----------------------------------------------------------------- chips */ diff --git a/src/flask/static/js/app.js b/src/flask/static/js/app.js index dd131fc..a8b0400 100644 --- a/src/flask/static/js/app.js +++ b/src/flask/static/js/app.js @@ -207,36 +207,12 @@ } } - function labelTableCells() { - var tables = document.querySelectorAll("table.data"); - for (var t = 0; t < tables.length; t++) { - var headerRow = tables[t].querySelector("thead tr"); - if (!headerRow) { continue; } - var labels = []; - var headers = headerRow.querySelectorAll("th"); - for (var h = 0; h < headers.length; h++) { - labels.push(headers[h].textContent.trim()); - } - - var rows = tables[t].querySelectorAll("tbody tr"); - for (var r = 0; r < rows.length; r++) { - var cells = rows[r].querySelectorAll("td"); - for (var c = 0; c < cells.length && c < labels.length; c++) { - if (labels[c] && !cells[c].getAttribute("data-label")) { - cells[c].setAttribute("data-label", labels[c]); - } - } - } - } - } - function init() { focusFirstEmpty(); defaultDateFields(); wireToggles(); wireConfirms(); wireAdvicePopouts(); - labelTableCells(); } if (document.readyState === "loading") { diff --git a/src/flask/templates/analysis.html b/src/flask/templates/analysis.html index 7476bdd..57f1704 100644 --- a/src/flask/templates/analysis.html +++ b/src/flask/templates/analysis.html @@ -99,43 +99,43 @@ - - - - - - - - - + + + + + + + + + {% for row in rows %} - - - + + - - - - - - + + + {% endfor %} @@ -155,21 +155,21 @@
RunNameOGFGABVAttenuationDaysTo 1.010FinishedRunNameOGFGABVAttenuationAtt.DaysTo 1.0101.010FinishedDone
{{ row.beer_run_id }}{{ row.name }}{% if row.suspect %} not counted{% endif %} + {{ row.beer_run_id }}{{ row.name }}{% if row.suspect %} not counted{% endif %} {% if row.og_points is not none %} {{ '%.3f' % (row.og_points / 1000) }} {% else %}—{% endif %} + {% if row.fg_points is not none %} {{ '%.3f' % (row.fg_points / 1000) }} {% else %}—{% endif %} + {% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %} + {% if row.attenuation is not none %} {{ row.attenuation }}% {% else %}—{% endif %} {{ row.days if row.days is not none else '—' }}{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}{{ row.final_date or '—' }}{{ row.days if row.days is not none else '—' }}{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}{{ row.final_date or '—' }}
- - - - + + + + {% for row in suspect %} - - - - + + + {% endfor %} diff --git a/src/flask/templates/brews.html b/src/flask/templates/brews.html index 0bebe60..7640b8a 100644 --- a/src/flask/templates/brews.html +++ b/src/flask/templates/brews.html @@ -78,48 +78,52 @@
RunOGFGProblemRunOGFGProblem
+ {{ row.beer_run_id }} {{ '%.3f' % (row.og_points / 1000) }}{{ '%.3f' % (row.fg_points / 1000) }}{{ row.problem }}{{ '%.3f' % (row.og_points / 1000) }}{{ '%.3f' % (row.fg_points / 1000) }}{{ row.problem }}
- - - - - - - + + + + + + + {% for run in active_runs %} - - - + + - - - - @@ -163,41 +167,41 @@
RunNameGravityEst. ABVCommentBrewing adviceRunNameGravityEst. ABVABVCommentNoteBrewing adviceAdvice
{{ run.beer_run_id }}{{ run.canonical_name }} + {{ run.beer_run_id }}{{ run.canonical_name }} {% if run.min_sg is not none %} {{ run.min_sg_decimal }} -
{{ run.min_sg_point_scale }} points
+
{{ run.min_sg_point_scale }} points
{% else %}—{% endif %}
+ {% if run.projected_abv is not none %} {{ '%.2f' % run.projected_abv }}% {% else %} — {% endif %} + {% if run.comments %} {{ run.comments[-1] }} {% else %} — {% endif %} + {% if run.advice %} + Open
- - - - - - - - + + + + + + + + {% for run in completed_runs %} - - - + + - - - - + - diff --git a/src/flask/templates/history.html b/src/flask/templates/history.html index a6c1530..ef00867 100644 --- a/src/flask/templates/history.html +++ b/src/flask/templates/history.html @@ -84,21 +84,21 @@
RunNameOGFGABVFinishedCommentRunNameOGFGABVFinishedDoneCommentNote
{{ run.beer_run_id }}{{ run.canonical_name }} + {{ run.beer_run_id }}{{ run.canonical_name }} {% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %} + {% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %} + {% if run.abv is not none %} {{ '%.2f' % run.abv }}% {% else %}—{% endif %} {{ run.final_date or '—' }} + {{ run.final_date or '—' }} {% if run.comments %} {{ run.comments[-1] }} {% else %} — {% endif %} + Open
- - - - - + + + + + {% for row in outliers %} - - - - - + + + + + {% endfor %} @@ -118,45 +118,45 @@
RunNameDateReadingCommentRunNameDateReadingCommentNote
{{ row.beer_run_id }}{{ row.name }}{{ row.date or '—' }}{{ row.sg }}{{ row.comment or '—' }}{{ row.beer_run_id }}{{ row.name }}{{ row.date or '—' }}{{ row.sg }}{{ row.comment or '—' }}
- - - - - - - - - - - + + + + + + + + + + + {% for run in summaries %} - - - + + - - - - + + + - - - + + - @@ -178,29 +178,29 @@
RunNameStatusOGFGDropABVLegacyFinishedCommentRunNameStatusStatOGFGDropABVLegacyFinishedDoneCommentNote
{{ run.beer_run_id }}{{ run.canonical_name }} + {{ run.beer_run_id }}{{ run.canonical_name }} {% if run.active %} Active {% else %} Finished {% endif %} {{ run.og_points or '—' }}{{ run.fg_points or '—' }}{{ run.drop_points if run.drop_points is not none else '—' }} + {{ run.og_points or '—' }}{{ run.fg_points or '—' }}{{ run.drop_points if run.drop_points is not none else '—' }} {% if run.abv is not none %}{{ '%.2f' % run.abv }}% {% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%* {% else %}—{% endif %} {{ run.legacy_abv if run.legacy_abv is not none else '—' }}{{ run.final_date or '—' }} + {{ run.legacy_abv if run.legacy_abv is not none else '—' }}{{ run.final_date or '—' }} {% if run.comments %}{{ run.comments[-1] }}{% else %}—{% endif %} + Open
- - - - - - + + + + + + {% for row in abv_rows %} - - - - - + + + + - + {% endfor %} diff --git a/tests/test_brew_advice.py b/tests/test_brew_advice.py index 8fc5e98..a9c438b 100644 --- a/tests/test_brew_advice.py +++ b/tests/test_brew_advice.py @@ -1024,6 +1024,31 @@ def test_the_advice_in_the_table_is_a_popout_not_the_text(client, repo): assert "Warm it to 21C and wait." in body +def test_the_tables_keep_their_columns_on_a_phone(client, repo): + """The tables are readable on a phone, not squeezed into nothing. + + The advice is a button and the table scrolls, so nothing is + truncated to an unreadable stub. + """ + 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", "Cider raspberry elderberry", "1045", "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 long heading has a short form for a phone, and the long form + # is still in the page for a desktop. + assert "Brewing advice" in body + assert ">Advice<" in body.replace("\n", "") + + # The beer name is not truncated in the markup. + assert "Cider raspberry elderberry" 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"})
RunNameStandard ABVLegacy ABVOverstated byFinishedRunNameStandard ABVStdLegacy ABVOldOverstated byOverFinishedDone
{{ row.beer_run_id }}{{ row.name }}{{ '%.2f' % row.abv }}%{{ '%.2f' % row.legacy_abv }}% + {{ row.beer_run_id }}{{ row.name }}{{ '%.2f' % row.abv }}%{{ '%.2f' % row.legacy_abv }}% {% if row.delta > 0 %} +{{ '%.2f' % row.delta }} {% else %} {{ '%.2f' % row.delta }} {% endif %} {{ row.final_date or '—' }}{{ row.final_date or '—' }}