Compare commits
5
Commits
e83dd212c1
...
21fcdf485c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21fcdf485c | ||
|
|
7dd39190f1 | ||
|
|
55b6068b3b | ||
|
|
c821a02d6e | ||
|
|
794df0c12e |
+262
-17
@@ -480,6 +480,138 @@ 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 .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
|
||||||
table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||||
|
|
||||||
|
/* On a narrow screen the tables reflow into stacked cards.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each row becomes a card: the header cell becomes a label above its
|
||||||
|
value, and the cells stack. */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__logout {
|
||||||
|
order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
order: 3;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
/* A fade at the right edge so a clipped tab reads as
|
||||||
|
scrollable rather than broken. */
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav__link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighter tabs, so all four fit on a phone without scrolling.
|
||||||
|
They still scroll if they have to. */
|
||||||
|
.nav__link {
|
||||||
|
padding: .35rem .55rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 */
|
/* ----------------------------------------------------------------- chips */
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@@ -595,8 +727,18 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
||||||
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
|
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
|
||||||
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||||
|
|
||||||
|
/* The three stats sit side by side, so their captions line up. One
|
||||||
|
caption ("needs 2nd reading") is two lines and the others are one,
|
||||||
|
which left the baselines ragged. */
|
||||||
|
.brew-card__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.brew-card__stat .stat__label { font-size: .68rem; }
|
.brew-card__stat .stat__label { font-size: .68rem; }
|
||||||
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
||||||
|
.brew-card__stat .stat__meta { margin-top: auto; }
|
||||||
.brew-card__spark { height: 46px; }
|
.brew-card__spark { height: 46px; }
|
||||||
|
|
||||||
/* The "start a new beer" tile — fills the last grid slot and gives the
|
/* The "start a new beer" tile — fills the last grid slot and gives the
|
||||||
@@ -733,27 +875,130 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The advice in the table is a compact trigger that pops the full text
|
||||||
|
out on hover, focus or tap.
|
||||||
|
|
||||||
|
It used to be the text itself, clamped to four lines. The advice runs
|
||||||
|
to several hundred characters, and a cell that wide pushes the table
|
||||||
|
past the width of a phone, so the column ended up clipped off the
|
||||||
|
right edge and unreadable without sideways scrolling. */
|
||||||
.advice-cell {
|
.advice-cell {
|
||||||
max-width: 32rem;
|
position: relative;
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In the table the advice is clamped to a few lines - the full text is
|
.advice-trigger {
|
||||||
on the run detail page, where there is room to read it. */
|
display: inline-flex;
|
||||||
.advice-cell .advice {
|
align-items: center;
|
||||||
margin-top: 0;
|
gap: .35rem;
|
||||||
|
padding: .25rem .55rem;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber-text);
|
||||||
|
font: inherit;
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
display: -webkit-box;
|
line-height: 1.4;
|
||||||
-webkit-line-clamp: 4;
|
cursor: pointer;
|
||||||
line-clamp: 4;
|
white-space: nowrap;
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The clamped text opens up on hover, so the whole thing is readable
|
.advice-trigger:hover,
|
||||||
without leaving the page. */
|
.advice-trigger:focus-visible {
|
||||||
.advice-cell:hover .advice,
|
background: var(--amber);
|
||||||
.advice-cell:focus-within .advice {
|
color: var(--bg);
|
||||||
-webkit-line-clamp: unset;
|
}
|
||||||
line-clamp: unset;
|
|
||||||
overflow: visible;
|
.advice-trigger--failed {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: var(--danger-dim);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-trigger__icon {
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The pop-out.
|
||||||
|
|
||||||
|
It is `position: fixed` and anchored to the trigger by the script,
|
||||||
|
not absolutely positioned inside the cell. The table is a scroll
|
||||||
|
container, and an absolutely positioned panel inside it was clipped
|
||||||
|
by the container's own overflow - the advice was sliced in half by
|
||||||
|
the card's edge and the bottom border was cut off.
|
||||||
|
|
||||||
|
Fixed positioning takes it out of the table entirely, so it floats
|
||||||
|
above the page and is never cut by a scroll container. */
|
||||||
|
.advice-pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
display: none;
|
||||||
|
width: min(30rem, 92vw);
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: .8rem .9rem;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, .55);
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-pop__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-pop__head .advice__label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-pop__close {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-pop__close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-pop__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: .8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The script opens and closes the panel, and places it. The CSS only
|
||||||
|
decides whether it is shown. */
|
||||||
|
.advice-pop.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone there is no hover, so the trigger is a button the script
|
||||||
|
toggles, and the panel is pinned to the middle of the screen so it is
|
||||||
|
readable whatever the table's width. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.advice-cell {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the panel is centred on the screen rather than
|
||||||
|
anchored to the trigger - there is not the room to float it
|
||||||
|
beside the cell, and the trigger may be near the edge. */
|
||||||
|
.advice-pop {
|
||||||
|
top: 50% !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,187 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brewing advice pop-out.
|
||||||
|
*
|
||||||
|
* On a desktop the panel opens on hover, which the CSS does. A
|
||||||
|
* phone has no hover, so the trigger is a button that toggles it -
|
||||||
|
* and it is a real button, so it works with a keyboard and with a
|
||||||
|
* screen reader.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The brewing advice pop-out.
|
||||||
|
*
|
||||||
|
* The advice runs to several hundred characters. In a table cell it
|
||||||
|
* pushed the table past the width of a phone, so it is a compact
|
||||||
|
* trigger instead and the text is shown in a floating panel.
|
||||||
|
*
|
||||||
|
* The panel is position:fixed so it escapes the table's scroll
|
||||||
|
* container - absolutely positioned inside the cell it was clipped
|
||||||
|
* by the container and sliced in half by the card's edge.
|
||||||
|
*
|
||||||
|
* It opens on hover on a desktop, and on tap on a phone, which has
|
||||||
|
* no hover. The trigger is a real button, so it also works with a
|
||||||
|
* keyboard.
|
||||||
|
*/
|
||||||
|
function wireAdvicePopouts() {
|
||||||
|
var triggers = document.querySelectorAll(".advice-trigger");
|
||||||
|
for (var i = 0; i < triggers.length; i++) {
|
||||||
|
(function (trigger) {
|
||||||
|
var panel = trigger.parentNode.querySelector(".advice-pop");
|
||||||
|
if (!panel) { return; }
|
||||||
|
|
||||||
|
var closeTimer = null;
|
||||||
|
|
||||||
|
function isPhone() {
|
||||||
|
return window.matchMedia("(max-width: 720px)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
if (isPhone()) {
|
||||||
|
/* The CSS centres it on a phone - clear any
|
||||||
|
inline position left over from a desktop
|
||||||
|
resize. */
|
||||||
|
panel.style.top = "";
|
||||||
|
panel.style.left = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var box = trigger.getBoundingClientRect();
|
||||||
|
var gap = 10;
|
||||||
|
var height = panel.offsetHeight;
|
||||||
|
var width = panel.offsetWidth;
|
||||||
|
var spaceBelow = window.innerHeight - box.bottom - gap;
|
||||||
|
var spaceAbove = box.top - gap;
|
||||||
|
|
||||||
|
/* Prefer below; flip above when there is not enough
|
||||||
|
room and there is more room above. */
|
||||||
|
var top;
|
||||||
|
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
|
||||||
|
top = box.bottom + gap;
|
||||||
|
} else {
|
||||||
|
top = Math.max(gap, box.top - height - gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep it on screen horizontally. */
|
||||||
|
var left = Math.min(box.left, window.innerWidth - width - gap);
|
||||||
|
left = Math.max(gap, left);
|
||||||
|
|
||||||
|
panel.style.top = Math.round(top) + "px";
|
||||||
|
panel.style.left = Math.round(left) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
}
|
||||||
|
panel.classList.add("is-open");
|
||||||
|
trigger.setAttribute("aria-expanded", "true");
|
||||||
|
place();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
panel.classList.remove("is-open");
|
||||||
|
trigger.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSoon() {
|
||||||
|
/* A short delay, so the mouse can travel from the
|
||||||
|
trigger into the panel without it closing. */
|
||||||
|
if (closeTimer) { clearTimeout(closeTimer); }
|
||||||
|
closeTimer = setTimeout(function () {
|
||||||
|
closeTimer = null;
|
||||||
|
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (panel.classList.contains("is-open")) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
trigger.addEventListener("mouseenter", function () {
|
||||||
|
if (!isPhone()) { open(); }
|
||||||
|
});
|
||||||
|
trigger.addEventListener("mouseleave", closeSoon);
|
||||||
|
panel.addEventListener("mouseenter", function () {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panel.addEventListener("mouseleave", closeSoon);
|
||||||
|
|
||||||
|
var closeButton = panel.querySelector(".advice-pop__close");
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "Escape" && panel.classList.contains("is-open")) {
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function (event) {
|
||||||
|
if (!panel.contains(event.target) && event.target !== trigger) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (panel.classList.contains("is-open")) { place(); }
|
||||||
|
});
|
||||||
|
window.addEventListener("scroll", function () {
|
||||||
|
if (panel.classList.contains("is-open")) { close(); }
|
||||||
|
}, true);
|
||||||
|
})(triggers[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function init() {
|
||||||
focusFirstEmpty();
|
focusFirstEmpty();
|
||||||
defaultDateFields();
|
defaultDateFields();
|
||||||
wireToggles();
|
wireToggles();
|
||||||
wireConfirms();
|
wireConfirms();
|
||||||
|
wireAdvicePopouts();
|
||||||
|
labelTableCells();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -184,8 +184,16 @@
|
|||||||
// Only finished brews have a measured ABV. An unfinished run
|
// Only finished brews have a measured ABV. An unfinished run
|
||||||
// has a projection, not a measurement, so it is not plotted
|
// has a projection, not a measurement, so it is not plotted
|
||||||
// here.
|
// here.
|
||||||
|
//
|
||||||
|
// A suspect run is left out too. Its ABV comes from an
|
||||||
|
// impossible reading (run 12 reads 18.64%), and plotting it
|
||||||
|
// stretched the y axis so that every real bar was squashed
|
||||||
|
// into the bottom of the plot - while the "Strongest" tile
|
||||||
|
// on the same page correctly showed 8.53%. The chart and
|
||||||
|
// the tile contradicted each other.
|
||||||
var completed = runs.filter(function (r) {
|
var completed = runs.filter(function (r) {
|
||||||
return r.abv !== null && r.abv !== undefined;
|
return !r.suspect &&
|
||||||
|
r.abv !== null && r.abv !== undefined;
|
||||||
});
|
});
|
||||||
if (!completed.length) { return; }
|
if (!completed.length) { return; }
|
||||||
|
|
||||||
@@ -305,8 +313,14 @@
|
|||||||
// A run with an impossible reading has no attenuation, and
|
// A run with an impossible reading has no attenuation, and
|
||||||
// is flagged as suspect. Both are excluded, so one bad
|
// is flagged as suspect. Both are excluded, so one bad
|
||||||
// reading cannot become a bar in the chart.
|
// reading cannot become a bar in the chart.
|
||||||
|
//
|
||||||
|
// Active runs are excluded as well. Their attenuation is
|
||||||
|
// measured against their CURRENT gravity, so a brew that
|
||||||
|
// has just started shows a 0% bar - which is not a result,
|
||||||
|
// it is the absence of one. The chart is about finished
|
||||||
|
// brews, and it sits above the table of finished brews.
|
||||||
var done = runs.filter(function (r) {
|
var done = runs.filter(function (r) {
|
||||||
return !r.suspect &&
|
return !r.suspect && !r.active &&
|
||||||
r.attenuation !== null && r.attenuation !== undefined;
|
r.attenuation !== null && r.attenuation !== undefined;
|
||||||
});
|
});
|
||||||
if (!done.length) { return; }
|
if (!done.length) { return; }
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<div class="brew-card__meta">
|
<div class="brew-card__meta">
|
||||||
Run {{ run.beer_run_id }}
|
Run {{ run.beer_run_id }}
|
||||||
{% if run.first_date %} · since {{ run.first_date }}{% endif %}
|
{% if run.first_date %} · since {{ run.first_date }}{% endif %}
|
||||||
{% if run.reading_count == 1 %} · 1 reading{% else %} · {{ run.reading_count }} readings{% endif %}
|
{% if run.reading_count == 1 %} · 1 reading{% else %} · {{ run.reading_count }} readings{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if run.tap_number is not none %}
|
{% if run.tap_number is not none %}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<p>
|
<p>
|
||||||
Across {% if stats.suspect_count %}{{ stats.trusted_count }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
|
Across {% if stats.suspect_count %}{{ stats.counted_attenuation }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
|
||||||
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
|
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
|
||||||
deviation of <strong>{{ stats.consistency.stdev }} percentage
|
deviation of <strong>{{ stats.consistency.stdev }} percentage
|
||||||
points</strong> and a total range of {{ stats.consistency.spread }}
|
points</strong> and a total range of {{ stats.consistency.spread }}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
<td class="num">
|
<td class="num">
|
||||||
{% if run.min_sg is not none %}
|
{% if run.min_sg is not none %}
|
||||||
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||||
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
|
<div class="faint tiny">{{ run.min_sg_point_scale }} points</div>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
@@ -112,9 +112,24 @@
|
|||||||
<span class="faint">—</span>
|
<span class="faint">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="comment advice-cell">
|
<td class="advice-cell">
|
||||||
{% if run.advice %}
|
{% if run.advice %}
|
||||||
<span class="advice{% if not run.advice_ok %} advice--failed{% endif %}">{{ run.advice }}</span>
|
<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">✎</span>
|
||||||
|
<span class="advice-trigger__label">
|
||||||
|
{% if run.advice_ok %}Read the advice{% else %}Advice failed{% endif %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="advice-pop" role="tooltip">
|
||||||
|
<div class="advice-pop__head">
|
||||||
|
<span class="advice__label">Brewing advice</span>
|
||||||
|
<button type="button" class="advice-pop__close"
|
||||||
|
aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="advice-pop__text">{{ run.advice }}</p>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint">Thinking…</span>
|
<span class="faint">Thinking…</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Average ABV</div>
|
<div class="stat__label">Average ABV</div>
|
||||||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||||||
<div class="stat__meta">across finished brews</div>
|
<div class="stat__meta">
|
||||||
|
{% if stats.abv_counted < stats.abv_total %}
|
||||||
|
across {{ stats.abv_counted }} of {{ stats.abv_total }} finished brews
|
||||||
|
{% else %}
|
||||||
|
across finished brews
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Strongest</div>
|
<div class="stat__label">Strongest</div>
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ def deep():
|
|||||||
suspect = implausible_runs(finished)
|
suspect = implausible_runs(finished)
|
||||||
suspect_ids = {s["beer_run_id"] for s in suspect}
|
suspect_ids = {s["beer_run_id"] for s in suspect}
|
||||||
|
|
||||||
|
# A run that is still fermenting can also have an impossible reading,
|
||||||
|
# and the API flags those too - so the charts leave them out. If they
|
||||||
|
# were not listed here they would disappear from the analysis page
|
||||||
|
# entirely, with no explanation anywhere.
|
||||||
|
active_suspect = implausible_runs([r for r in runs if r.is_active()])
|
||||||
|
if active_suspect:
|
||||||
|
suspect = suspect + active_suspect
|
||||||
|
suspect_ids |= {s["beer_run_id"] for s in active_suspect}
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for run in finished:
|
for run in finished:
|
||||||
meta = metadata.get(run.beer_run_id, {})
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
@@ -111,6 +120,10 @@ def deep():
|
|||||||
"suspect_count": len(suspect),
|
"suspect_count": len(suspect),
|
||||||
# How many runs the figures are actually based on.
|
# How many runs the figures are actually based on.
|
||||||
"trusted_count": len(good),
|
"trusted_count": len(good),
|
||||||
|
# How many of those actually contributed a figure to the
|
||||||
|
# average - a run with no original gravity has no attenuation,
|
||||||
|
# so it is counted above but not in the mean.
|
||||||
|
"counted_attenuation": len(attenuations),
|
||||||
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
||||||
if attenuations else None,
|
if attenuations else None,
|
||||||
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
||||||
@@ -278,5 +291,9 @@ def history():
|
|||||||
"avg_abv": avg_abv,
|
"avg_abv": avg_abv,
|
||||||
"best": best,
|
"best": best,
|
||||||
"worst": worst,
|
"worst": worst,
|
||||||
|
# How many of the finished brews the ABV figures are based
|
||||||
|
# on, so the tile can say so.
|
||||||
|
"abv_counted": len(good_abv_rows),
|
||||||
|
"abv_total": len(completed),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ def api_runs():
|
|||||||
# The runs whose readings are physically impossible. They are flagged
|
# The runs whose readings are physically impossible. They are flagged
|
||||||
# rather than dropped: the charts use the flag to leave them out of the
|
# rather than dropped: the charts use the flag to leave them out of the
|
||||||
# averages and the curve envelope, but a caller can still see them.
|
# averages and the curve envelope, but a caller can still see them.
|
||||||
|
#
|
||||||
|
# The flag is computed over EVERY run, not just the finished ones, so
|
||||||
|
# it agrees with what the charts do. An active run with a bad reading
|
||||||
|
# is still a run with a bad reading.
|
||||||
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
||||||
|
|
||||||
payload = []
|
payload = []
|
||||||
@@ -368,6 +372,7 @@ def api_run(run_id: str):
|
|||||||
# The same flag the list endpoint carries, so a consumer of the detail
|
# The same flag the list endpoint carries, so a consumer of the detail
|
||||||
# endpoint can tell an impossible run from a good one.
|
# endpoint can tell an impossible run from a good one.
|
||||||
summary["suspect"] = bool(implausible_runs([run]))
|
summary["suspect"] = bool(implausible_runs([run]))
|
||||||
|
summary["active"] = run.is_active()
|
||||||
return jsonify(summary)
|
return jsonify(summary)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -508,3 +508,121 @@ def test_the_charts_fetch_the_run_payload_once():
|
|||||||
"the run payload should be fetched once and shared"
|
"the run payload should be fetched once and shared"
|
||||||
)
|
)
|
||||||
assert "function fetchRuns()" in charts_js
|
assert "function fetchRuns()" in charts_js
|
||||||
|
|
||||||
|
# ------------------------------------------------- the chart exclusions #
|
||||||
|
|
||||||
|
def test_the_abv_chart_leaves_out_the_suspect_runs():
|
||||||
|
"""A suspect run's ABV comes from an impossible reading.
|
||||||
|
|
||||||
|
Run 12 reads 18.64%, and plotting it stretched the y axis so every
|
||||||
|
real bar was squashed into the bottom of the plot - while the
|
||||||
|
"Strongest" tile on the same page correctly showed 8.53%. The chart
|
||||||
|
and the tile contradicted each other.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
abv_chart = charts_js.split("function drawAbvChart")[1]
|
||||||
|
abv_chart = abv_chart.split("function drawMonthsChart")[0]
|
||||||
|
assert "!r.suspect" in abv_chart, (
|
||||||
|
"the ABV chart must leave the suspect runs out"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_attenuation_chart_leaves_out_the_active_runs():
|
||||||
|
"""An active run's attenuation is measured against its CURRENT gravity.
|
||||||
|
|
||||||
|
A brew that has just started shows a 0% bar - which is the absence of
|
||||||
|
a result, not a result. The chart sits above the table of finished
|
||||||
|
brews.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
att_chart = charts_js.split("function drawAttenuationChart")[1]
|
||||||
|
att_chart = att_chart.split("function drawCurveChart")[0]
|
||||||
|
assert "!r.active" in att_chart, (
|
||||||
|
"the attenuation chart must leave the active runs out"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_suspect_active_run_is_still_listed_for_review(logged_in, repo):
|
||||||
|
"""A bad reading in an active run must not make the run vanish.
|
||||||
|
|
||||||
|
The API flags suspect runs over EVERY run, so the charts leave them
|
||||||
|
out. If the analysis page only looked at the finished ones, an
|
||||||
|
active run with a bad reading would disappear with no explanation
|
||||||
|
anywhere.
|
||||||
|
"""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Finished ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Finished ale", "1010", "2026-01-10", True, ""),
|
||||||
|
# An ACTIVE run whose latest reading is below water.
|
||||||
|
("20", "Active brew", "1042", "2026-02-01", False, ""),
|
||||||
|
("20", "Active brew", "0900", "2026-02-10", False, "typo"),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/analysis")
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert "Readings that need a second look" in body
|
||||||
|
assert "0.900" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_history_page_says_how_many_brews_the_abv_figure_is_from(logged_in, repo):
|
||||||
|
"""The tile must not quietly average fewer brews than it claims."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Good ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Good ale", "1010", "2026-01-10", True, ""),
|
||||||
|
("12", "Bad brew", "1042", "2026-01-01", False, ""),
|
||||||
|
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/history")
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
assert "across 1 of 2 finished brews" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_analysis_prose_counts_the_brews_that_contributed(logged_in, repo):
|
||||||
|
"""The prose must not claim more brews than actually contributed.
|
||||||
|
|
||||||
|
The prose is rendered from the attenuation figures, so it counts the
|
||||||
|
brews that produced one - not the brews that were merely counted.
|
||||||
|
"""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Good ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Good ale", "1010", "2026-01-10", True, ""),
|
||||||
|
("2", "Good pale", "1042", "2026-02-01", False, ""),
|
||||||
|
("2", "Good pale", "1012", "2026-02-10", True, ""),
|
||||||
|
("12", "Bad brew", "1042", "2026-01-01", False, ""),
|
||||||
|
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/analysis")
|
||||||
|
body = " ".join(response.get_data(as_text=True).split())
|
||||||
|
|
||||||
|
# Three finished brews, two of which contributed an attenuation.
|
||||||
|
assert "Across 2 of 3 finished brews" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_run_detail_api_carries_the_same_flags(logged_in, repo):
|
||||||
|
"""A consumer of the detail endpoint can tell an impossible run from a
|
||||||
|
good one, and an active run from a finished one."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Good ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Good ale", "1010", "2026-01-10", True, ""),
|
||||||
|
("20", "Active brew", "1042", "2026-02-01", False, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
finished = logged_in.get("/api/runs/1").get_json()
|
||||||
|
assert finished["suspect"] is False
|
||||||
|
assert finished["active"] is False
|
||||||
|
|
||||||
|
active = logged_in.get("/api/runs/20").get_json()
|
||||||
|
assert active["suspect"] is False
|
||||||
|
assert active["active"] is True
|
||||||
|
|||||||
@@ -995,6 +995,35 @@ def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
|
|||||||
assert "Brewing advice" in body
|
assert "Brewing advice" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_in_the_table_is_a_popout_not_the_text(client, repo):
|
||||||
|
"""The advice in the table is a trigger, and the text is in a panel.
|
||||||
|
|
||||||
|
The advice is several hundred characters. In a table cell it made
|
||||||
|
the table wider than a phone, so the column was clipped off the
|
||||||
|
right edge and the advice could not be read.
|
||||||
|
"""
|
||||||
|
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 trigger is a real button, so it works with a keyboard.
|
||||||
|
assert "advice-trigger" in body
|
||||||
|
assert 'class="advice-trigger' in body
|
||||||
|
|
||||||
|
# The text lives in the panel, not in the cell.
|
||||||
|
assert "advice-pop" in body
|
||||||
|
assert "advice-pop__text" in body
|
||||||
|
|
||||||
|
# The full advice is in the page - it is just not in the cell.
|
||||||
|
assert "Warm it to 21C and wait." in body
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_advice_is_marked_as_such(client, repo):
|
def test_a_failed_advice_is_marked_as_such(client, repo):
|
||||||
"""A generation failure reads differently from real advice."""
|
"""A generation failure reads differently from real advice."""
|
||||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
@@ -1008,7 +1037,9 @@ def test_a_failed_advice_is_marked_as_such(client, repo):
|
|||||||
|
|
||||||
response = client.get("/brews")
|
response = client.get("/brews")
|
||||||
body = response.get_data(as_text=True)
|
body = response.get_data(as_text=True)
|
||||||
assert "advice--failed" in body
|
# A failed generation is marked differently from real advice.
|
||||||
|
assert "advice-trigger--failed" in body
|
||||||
|
assert "Advice failed" in body
|
||||||
|
|
||||||
|
|
||||||
def test_advice_is_not_generated_in_tests(client, repo):
|
def test_advice_is_not_generated_in_tests(client, repo):
|
||||||
|
|||||||
Reference in New Issue
Block a user