fix(ui): keep the table columns readable on a phone #18

Merged
hermes merged 1 commits from fix/mobile-table-columns into master 2026-11-09 21:48:20 +10:00
6 changed files with 232 additions and 209 deletions
+101 -83
View File
@@ -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 */
-24
View File
@@ -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") {
+26 -26
View File
@@ -99,43 +99,43 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th class="num">Attenuation</th>
<th class="num">Days</th>
<th class="num">To 1.010</th>
<th>Finished</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="num col-abv" title="Attenuation"><span class="long">Attenuation</span><span class="short">Att.</span></th>
<th class="num col-abv col-days">Days</th>
<th class="num col-abv" title="Days to reach 1.010"><span class="long">To 1.010</span><span class="short">1.010</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="num">
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="col-gravity num">
{% if row.og_points is not none %}
{{ '%.3f' % (row.og_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-gravity num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-abv num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-abv num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
<td class="col-abv col-days num">{{ row.days if row.days is not none else '—' }}</td>
<td class="col-abv num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -155,21 +155,21 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th>Problem</th>
<th class="col-run">Run</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="col-comment">Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="mono">
<td class="col-run mono">
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td>
<td class="col-gravity num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="col-comment comment">{{ row.problem }}</td>
</tr>
{% endfor %}
</tbody>
+36 -32
View File
@@ -78,48 +78,52 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th>Brewing advice</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">Gravity</th>
<th class="num col-abv" title="Estimated alcohol by volume"><span class="long">Est. ABV</span><span class="short">ABV</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-advice" title="Brewing advice"><span class="long">Brewing advice</span><span class="short">Advice</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in active_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} points</div>
<div class="faint tiny point-scale">{{ run.min_sg_point_scale }} points</div>
{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.projected_abv is not none %}
{{ '%.2f' % run.projected_abv }}%
{% else %}
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
{% endif %}
</td>
<td class="comment">
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="advice-cell">
<td class="advice-cell col-advice">
{% if run.advice %}
<button type="button"
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
aria-expanded="false">
<span class="advice-trigger__icon" aria-hidden="true">&#9998;</span>
<span class="advice-trigger__label">
{% if run.advice_ok %}Read the advice{% else %}Advice failed{% endif %}
{% if run.advice_ok %}
<span class="long">Read the advice</span><span class="short">Advice</span>
{% else %}
<span class="long">Advice failed</span><span class="short">Failed</span>
{% endif %}
</span>
</button>
<div class="advice-pop" role="tooltip">
@@ -134,7 +138,7 @@
<span class="faint">Thinking…</span>
{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
@@ -163,41 +167,41 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in completed_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-gravity">
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.abv is not none %}
<strong>{{ '%.2f' % run.abv }}%</strong>
{% else %}—{% endif %}
</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
<td class="mono nowrap col-finished">{{ run.final_date or '—' }}</td>
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
+44 -44
View File
@@ -84,21 +84,21 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Date</th>
<th class="num">Reading</th>
<th>Comment</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished">Date</th>
<th class="num col-gravity">Reading</th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
</tr>
</thead>
<tbody>
{% for row in outliers %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="mono nowrap">{{ row.date or '—' }}</td>
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="comment">{{ row.comment or '—' }}</td>
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-finished mono nowrap">{{ row.date or '—' }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="col-comment comment">{{ row.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -118,45 +118,45 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Status</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">Drop</th>
<th class="num">ABV</th>
<th class="num">Legacy</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished" title="Status"><span class="long">Status</span><span class="short">Stat</span></th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-drop">Drop</th>
<th class="num col-abv">ABV</th>
<th class="num col-legacy">Legacy</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in summaries %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td>
<td class="col-run mono">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="col-finished">
{% if run.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</td>
<td class="num">{{ run.og_points or '—' }}</td>
<td class="num">{{ run.fg_points or '—' }}</td>
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="num">
<td class="col-drop col-gravity num">{{ run.og_points or '—' }}</td>
<td class="col-gravity num">{{ run.fg_points or '—' }}</td>
<td class="col-gravity num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="col-abv num">
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
{% else %}—{% endif %}
</td>
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
<td class="col-legacy col-abv num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="col-finished mono nowrap">{{ run.final_date or '—' }}</td>
<td class="col-comment comment">
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
@@ -178,29 +178,29 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Standard ABV</th>
<th class="num">Legacy ABV</th>
<th class="num">Overstated by</th>
<th>Finished</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-abv" title="Standard ABV"><span class="long">Standard ABV</span><span class="short">Std</span></th>
<th class="num col-legacy col-abv" title="Legacy ABV"><span class="long">Legacy ABV</span><span class="short">Old</span></th>
<th class="num col-abv" title="Overstated by"><span class="long">Overstated by</span><span class="short">Over</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in abv_rows %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="num">{{ '%.2f' % row.abv }}%</td>
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="num">
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-abv num">{{ '%.2f' % row.abv }}%</td>
<td class="col-abv num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="col-abv num">
{% if row.delta > 0 %}
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
{% else %}
<span class="faint">{{ '%.2f' % row.delta }}</span>
{% endif %}
</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
<td class="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
+25
View File
@@ -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"})