Compare commits
18
Commits
e83dd212c1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4770f870 | ||
|
|
fb1088ceec | ||
|
|
cd45cc2f17 | ||
|
|
e603f23928 | ||
|
|
a38febaca8 | ||
|
|
ef0d665b78 | ||
|
|
81ebdf3c76 | ||
|
|
e473850aac | ||
|
|
571e7cb8c8 | ||
|
|
27b2611a20 | ||
|
|
81b2a17b37 | ||
|
|
2600622af8 | ||
|
|
af28ecbd1b | ||
|
|
21fcdf485c | ||
|
|
7dd39190f1 | ||
|
|
55b6068b3b | ||
|
|
c821a02d6e | ||
|
|
794df0c12e |
@@ -87,6 +87,31 @@ def create_app(config: dict | None = None) -> Flask:
|
|||||||
if config:
|
if config:
|
||||||
app.config.update(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"
|
||||||
|
elif response.mimetype in ("text/css", "application/javascript",
|
||||||
|
"text/javascript"):
|
||||||
|
# The static files must NEVER be reused from a cache.
|
||||||
|
|
||||||
|
# Firefox kept serving a stale stylesheet even with
|
||||||
|
# `no-cache`, which meant the page had the new markup and
|
||||||
|
# the old CSS: the advice was expanded inline in the table
|
||||||
|
# instead of in a pop-out, and the long and short labels
|
||||||
|
# were both showing ("BREWING ADVICEADVICE").
|
||||||
|
|
||||||
|
# `no-store` is the only one that means "do not keep this".
|
||||||
|
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Two collections are used:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -232,7 +233,8 @@ class BeerRepository:
|
|||||||
def set_reading_advice(self, reading_id: str, advice: str,
|
def set_reading_advice(self, reading_id: str, advice: str,
|
||||||
ok: bool = True,
|
ok: bool = True,
|
||||||
expected_sg: int | None = None,
|
expected_sg: int | None = None,
|
||||||
expected_token: str | None = None) -> int:
|
expected_token: str | None = None,
|
||||||
|
expected_comment: str | None = None) -> int:
|
||||||
"""Attach the LLM's advice to a reading.
|
"""Attach the LLM's advice to a reading.
|
||||||
|
|
||||||
The advice lives in its own field, so the brewer's own comment is
|
The advice lives in its own field, so the brewer's own comment is
|
||||||
@@ -286,6 +288,31 @@ class BeerRepository:
|
|||||||
# A direct call with a gravity to check against.
|
# A direct call with a gravity to check against.
|
||||||
query["sg"] = str(expected_sg)
|
query["sg"] = str(expected_sg)
|
||||||
|
|
||||||
|
if expected_comment is not None:
|
||||||
|
# The comment the advice was written for.
|
||||||
|
#
|
||||||
|
# This is a SECOND, independent guard. The token covers
|
||||||
|
# every edit the application makes, but a document edited
|
||||||
|
# outside the application - by a script, or by hand in a
|
||||||
|
# database tool - would keep its empty token and a stale
|
||||||
|
# thread's write would still match on the gravity alone.
|
||||||
|
#
|
||||||
|
# The advice is generated from the comment as well as the
|
||||||
|
# gravity, so the comment is part of what the advice
|
||||||
|
# describes. Matching on it means a stale write can only
|
||||||
|
# land if BOTH the gravity and the comment are unchanged.
|
||||||
|
#
|
||||||
|
# The stored comment and the one the view sees can differ by
|
||||||
|
# trailing whitespace, so a regex is used to match either.
|
||||||
|
#
|
||||||
|
# `^comment\s*$` matches the stored value with any trailing
|
||||||
|
# whitespace, and the exact value too. It is anchored, so it
|
||||||
|
# cannot match a longer comment that merely starts the same
|
||||||
|
# way.
|
||||||
|
query["comment"] = {
|
||||||
|
"$regex": "^" + re.escape(expected_comment.strip()) + r"\s*$"
|
||||||
|
}
|
||||||
|
|
||||||
# `$in` matches either form. A plain list would be an exact
|
# `$in` matches either form. A plain list would be an exact
|
||||||
# ARRAY match in Mongo, which is not what is wanted here.
|
# ARRAY match in Mongo, which is not what is wanted here.
|
||||||
result = self.readings.update_one(
|
result = self.readings.update_one(
|
||||||
@@ -296,7 +323,13 @@ class BeerRepository:
|
|||||||
"advice_at": _now_iso(),
|
"advice_at": _now_iso(),
|
||||||
}},
|
}},
|
||||||
)
|
)
|
||||||
return result.modified_count
|
# `matched_count`, not `modified_count`.
|
||||||
|
#
|
||||||
|
# `modified_count` is 0 when the document matched but the text
|
||||||
|
# was already identical - which is a successful write, not a
|
||||||
|
# refused one. The caller logs "the advice was not stored" when
|
||||||
|
# this is 0, so it has to mean "the guard refused it".
|
||||||
|
return result.matched_count
|
||||||
|
|
||||||
def set_final_reading(self, beer_run_id: str, reading_date: str,
|
def set_final_reading(self, beer_run_id: str, reading_date: str,
|
||||||
final: bool) -> int:
|
final: bool) -> int:
|
||||||
|
|||||||
+123
-84
@@ -451,7 +451,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- tables */
|
/* ---------------------------------------------------------------- tables */
|
||||||
|
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
table.data {
|
table.data {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -480,21 +483,23 @@ 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 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.
|
||||||
|
|
||||||
A seven-column table does not fit a phone. It was left to scroll
|
The rule is on `.short` alone, not on `table.data .short`, so it
|
||||||
sideways inside its wrapper, which meant the columns on the right were
|
applies everywhere the pattern is used - in a table heading, in the
|
||||||
off the screen and the brewer had to pan the table to see them.
|
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; }
|
||||||
|
|
||||||
Each row becomes a card: the header cell becomes a label above its
|
/* On a narrow screen the header wraps onto two rows: the brand and the
|
||||||
value, and the cells stack. */
|
sign-out on the first, the nav tabs on the second where they get the
|
||||||
|
full width.
|
||||||
|
|
||||||
|
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) {
|
@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 {
|
.topbar__inner {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
row-gap: .5rem;
|
row-gap: .5rem;
|
||||||
@@ -537,79 +542,87 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
letter-spacing: .01em;
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.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 */
|
/* ----------------------------------------------------------------- chips */
|
||||||
@@ -965,7 +978,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 .15rem;
|
/* A 44px touch target, which is the smallest that is comfortable
|
||||||
|
on a phone. The negative margin pulls the panel's padding back
|
||||||
|
in so the button does not push the layout around, but the
|
||||||
|
padding is still what makes it easy to hit. */
|
||||||
|
padding: .9rem;
|
||||||
|
margin: -.9rem -.9rem -.9rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advice-pop__close:hover {
|
.advice-pop__close:hover {
|
||||||
@@ -981,10 +999,21 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
|
|
||||||
/* The script opens and closes the panel, and places it. The CSS only
|
/* The script opens and closes the panel, and places it. The CSS only
|
||||||
decides whether it is shown. */
|
decides whether it is shown. */
|
||||||
|
/* The panel is opened by the script, which removes the `hidden`
|
||||||
|
attribute and adds `is-open`.
|
||||||
|
|
||||||
|
The `hidden` attribute is what makes this fail-safe: if the
|
||||||
|
stylesheet is stale and does not know about `.advice-pop` at all,
|
||||||
|
the panel is STILL hidden, so the advice cannot appear expanded
|
||||||
|
inside the table cell. */
|
||||||
.advice-pop.is-open {
|
.advice-pop.is-open {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.advice-pop[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* On a phone there is no hover, so the trigger is a button the script
|
/* 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
|
toggles, and the panel is pinned to the middle of the screen so it is
|
||||||
readable whatever the table's width. */
|
readable whatever the table's width. */
|
||||||
@@ -993,6 +1022,16 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A dimmed backdrop, so the panel reads as sitting over the page
|
||||||
|
rather than floating in it.
|
||||||
|
|
||||||
|
A large spread shadow is used rather than a pseudo element, which
|
||||||
|
would paint behind the panel's own background. */
|
||||||
|
.advice-pop {
|
||||||
|
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .55),
|
||||||
|
0 12px 32px rgba(0, 0, 0, .55);
|
||||||
|
}
|
||||||
|
|
||||||
/* On a phone the panel is centred on the screen rather than
|
/* On a phone the panel is centred on the screen rather than
|
||||||
anchored to the trigger - there is not the room to float it
|
anchored to the trigger - there is not the room to float it
|
||||||
beside the cell, and the trigger may be near the edge. */
|
beside the cell, and the trigger may be near the edge. */
|
||||||
|
|||||||
@@ -131,6 +131,11 @@
|
|||||||
clearTimeout(closeTimer);
|
clearTimeout(closeTimer);
|
||||||
closeTimer = null;
|
closeTimer = null;
|
||||||
}
|
}
|
||||||
|
/* The `hidden` attribute is removed as well as the
|
||||||
|
class added, so the panel is shown even if the
|
||||||
|
stylesheet is an older one that only knows about
|
||||||
|
the class. */
|
||||||
|
panel.removeAttribute("hidden");
|
||||||
panel.classList.add("is-open");
|
panel.classList.add("is-open");
|
||||||
trigger.setAttribute("aria-expanded", "true");
|
trigger.setAttribute("aria-expanded", "true");
|
||||||
place();
|
place();
|
||||||
@@ -138,6 +143,9 @@
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
panel.classList.remove("is-open");
|
panel.classList.remove("is-open");
|
||||||
|
/* Put the attribute back, so a stale stylesheet
|
||||||
|
cannot leave the advice expanded in the cell. */
|
||||||
|
panel.setAttribute("hidden", "");
|
||||||
trigger.setAttribute("aria-expanded", "false");
|
trigger.setAttribute("aria-expanded", "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,36 +215,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() {
|
function init() {
|
||||||
focusFirstEmpty();
|
focusFirstEmpty();
|
||||||
defaultDateFields();
|
defaultDateFields();
|
||||||
wireToggles();
|
wireToggles();
|
||||||
wireConfirms();
|
wireConfirms();
|
||||||
wireAdvicePopouts();
|
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; }
|
||||||
|
|||||||
@@ -99,43 +99,43 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th class="num">OG</th>
|
<th class="num col-gravity">OG</th>
|
||||||
<th class="num">FG</th>
|
<th class="num col-gravity">FG</th>
|
||||||
<th class="num">ABV</th>
|
<th class="num col-abv">ABV</th>
|
||||||
<th class="num">Attenuation</th>
|
<th class="num col-abv" title="Attenuation"><span class="long">Attenuation</span><span class="short">Att.</span></th>
|
||||||
<th class="num">Days</th>
|
<th class="num col-abv col-days">Days</th>
|
||||||
<th class="num">To 1.010</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>Finished</th>
|
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
|
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
|
||||||
<td class="mono">{{ row.beer_run_id }}</td>
|
<td class="col-run mono">{{ row.beer_run_id }}</td>
|
||||||
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
|
<td class="col-name">{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
|
||||||
<td class="num">
|
<td class="col-gravity num">
|
||||||
{% if row.og_points is not none %}
|
{% if row.og_points is not none %}
|
||||||
{{ '%.3f' % (row.og_points / 1000) }}
|
{{ '%.3f' % (row.og_points / 1000) }}
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="col-gravity num">
|
||||||
{% if row.fg_points is not none %}
|
{% if row.fg_points is not none %}
|
||||||
{{ '%.3f' % (row.fg_points / 1000) }}
|
{{ '%.3f' % (row.fg_points / 1000) }}
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="col-abv num">
|
||||||
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
|
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="col-abv num">
|
||||||
{% if row.attenuation is not none %}
|
{% if row.attenuation is not none %}
|
||||||
<strong>{{ row.attenuation }}%</strong>
|
<strong>{{ row.attenuation }}%</strong>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
|
<td class="col-abv col-days 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="col-abv 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-finished mono nowrap">{{ row.final_date or '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -155,21 +155,21 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th class="num">OG</th>
|
<th class="num col-gravity">OG</th>
|
||||||
<th class="num">FG</th>
|
<th class="num col-gravity">FG</th>
|
||||||
<th>Problem</th>
|
<th class="col-comment">Problem</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in suspect %}
|
{% for row in suspect %}
|
||||||
<tr>
|
<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>
|
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
|
<td class="col-gravity num">{{ '%.3f' % (row.og_points / 1000) }}</td>
|
||||||
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
|
<td class="col-gravity num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
|
||||||
<td class="comment">{{ row.problem }}</td>
|
<td class="col-comment comment">{{ row.problem }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -78,51 +78,55 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th class="num">Gravity</th>
|
<th class="num col-gravity">Gravity</th>
|
||||||
<th class="num">Est. ABV</th>
|
<th class="num col-abv" title="Estimated alcohol by volume"><span class="long">Est. ABV</span><span class="short">ABV</span></th>
|
||||||
<th>Comment</th>
|
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
|
||||||
<th>Brewing advice</th>
|
<th class="col-advice" title="Brewing advice"><span class="long">Brewing advice</span><span class="short">Advice</span></th>
|
||||||
<th></th>
|
<th class="col-action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for run in active_runs %}
|
{% for run in active_runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ run.beer_run_id }}</td>
|
<td class="mono col-run">{{ run.beer_run_id }}</td>
|
||||||
<td>{{ run.canonical_name }}</td>
|
<td class="col-name">{{ run.canonical_name }}</td>
|
||||||
<td class="num">
|
<td class="num col-gravity">
|
||||||
{% 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 }} points</div>
|
<div class="faint tiny point-scale">{{ run.min_sg_point_scale }} points</div>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num col-abv">
|
||||||
{% if run.projected_abv is not none %}
|
{% if run.projected_abv is not none %}
|
||||||
{{ '%.2f' % run.projected_abv }}%
|
{{ '%.2f' % run.projected_abv }}%
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="comment">
|
<td class="comment col-comment">
|
||||||
{% if run.comments %}
|
{% if run.comments %}
|
||||||
{{ run.comments[-1] }}
|
{{ run.comments[-1] }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint">—</span>
|
<span class="faint">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="advice-cell">
|
<td class="advice-cell col-advice">
|
||||||
{% if run.advice %}
|
{% if run.advice %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
|
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
|
||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
<span class="advice-trigger__icon" aria-hidden="true">✎</span>
|
<span class="advice-trigger__icon" aria-hidden="true">✎</span>
|
||||||
<span class="advice-trigger__label">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="advice-pop" role="tooltip">
|
<div class="advice-pop" role="tooltip" hidden>
|
||||||
<div class="advice-pop__head">
|
<div class="advice-pop__head">
|
||||||
<span class="advice__label">Brewing advice</span>
|
<span class="advice__label">Brewing advice</span>
|
||||||
<button type="button" class="advice-pop__close"
|
<button type="button" class="advice-pop__close"
|
||||||
@@ -134,7 +138,7 @@
|
|||||||
<span class="faint">Thinking…</span>
|
<span class="faint">Thinking…</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="right">
|
<td class="right col-action">
|
||||||
<a class="btn btn--ghost btn--sm"
|
<a class="btn btn--ghost btn--sm"
|
||||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -163,41 +167,41 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th class="num">OG</th>
|
<th class="num col-gravity">OG</th>
|
||||||
<th class="num">FG</th>
|
<th class="num col-gravity">FG</th>
|
||||||
<th class="num">ABV</th>
|
<th class="num col-abv">ABV</th>
|
||||||
<th>Finished</th>
|
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
|
||||||
<th>Comment</th>
|
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
|
||||||
<th></th>
|
<th class="col-action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for run in completed_runs %}
|
{% for run in completed_runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ run.beer_run_id }}</td>
|
<td class="mono col-run">{{ run.beer_run_id }}</td>
|
||||||
<td>{{ run.canonical_name }}</td>
|
<td class="col-name">{{ run.canonical_name }}</td>
|
||||||
<td class="num">
|
<td class="num col-gravity">
|
||||||
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
|
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num col-gravity">
|
||||||
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
|
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num col-abv">
|
||||||
{% if run.abv is not none %}
|
{% if run.abv is not none %}
|
||||||
<strong>{{ '%.2f' % run.abv }}%</strong>
|
<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
<td class="mono nowrap col-finished">{{ run.final_date or '—' }}</td>
|
||||||
<td class="comment">
|
<td class="comment col-comment">
|
||||||
{% if run.comments %}
|
{% if run.comments %}
|
||||||
{{ run.comments[-1] }}
|
{{ run.comments[-1] }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint">—</span>
|
<span class="faint">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="right">
|
<td class="right col-action">
|
||||||
<a class="btn btn--ghost btn--sm"
|
<a class="btn btn--ghost btn--sm"
|
||||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -78,21 +84,21 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th>Date</th>
|
<th class="col-finished">Date</th>
|
||||||
<th class="num">Reading</th>
|
<th class="num col-gravity">Reading</th>
|
||||||
<th>Comment</th>
|
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in outliers %}
|
{% for row in outliers %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ row.beer_run_id }}</td>
|
<td class="col-run mono">{{ row.beer_run_id }}</td>
|
||||||
<td>{{ row.name }}</td>
|
<td class="col-name">{{ row.name }}</td>
|
||||||
<td class="mono nowrap">{{ row.date or '—' }}</td>
|
<td class="col-finished mono nowrap">{{ row.date or '—' }}</td>
|
||||||
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
<td class="col-gravity num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
||||||
<td class="comment">{{ row.comment or '—' }}</td>
|
<td class="col-comment comment">{{ row.comment or '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -112,45 +118,45 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th>Status</th>
|
<th class="col-finished" title="Status"><span class="long">Status</span><span class="short">Stat</span></th>
|
||||||
<th class="num">OG</th>
|
<th class="num col-gravity">OG</th>
|
||||||
<th class="num">FG</th>
|
<th class="num col-gravity">FG</th>
|
||||||
<th class="num">Drop</th>
|
<th class="num col-drop">Drop</th>
|
||||||
<th class="num">ABV</th>
|
<th class="num col-abv">ABV</th>
|
||||||
<th class="num">Legacy</th>
|
<th class="num col-legacy">Legacy</th>
|
||||||
<th>Finished</th>
|
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
|
||||||
<th>Comment</th>
|
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
|
||||||
<th></th>
|
<th class="col-action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for run in summaries %}
|
{% for run in summaries %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ run.beer_run_id }}</td>
|
<td class="col-run mono">{{ run.beer_run_id }}</td>
|
||||||
<td>{{ run.canonical_name }}</td>
|
<td class="col-name">{{ run.canonical_name }}</td>
|
||||||
<td>
|
<td class="col-finished">
|
||||||
{% if run.active %}
|
{% if run.active %}
|
||||||
<span class="chip chip--active">Active</span>
|
<span class="chip chip--active">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="chip chip--done">Finished</span>
|
<span class="chip chip--done">Finished</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">{{ run.og_points or '—' }}</td>
|
<td class="col-drop col-gravity num">{{ run.og_points or '—' }}</td>
|
||||||
<td class="num">{{ run.fg_points or '—' }}</td>
|
<td class="col-gravity num">{{ run.fg_points or '—' }}</td>
|
||||||
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
<td class="col-gravity num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
||||||
<td class="num">
|
<td class="col-abv num">
|
||||||
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||||
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
|
<td class="col-legacy col-abv 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="col-finished mono nowrap">{{ run.final_date or '—' }}</td>
|
||||||
<td class="comment">
|
<td class="col-comment comment">
|
||||||
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
|
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="right">
|
<td class="right col-action">
|
||||||
<a class="btn btn--ghost btn--sm"
|
<a class="btn btn--ghost btn--sm"
|
||||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -172,29 +178,29 @@
|
|||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Run</th>
|
<th class="col-run">Run</th>
|
||||||
<th>Name</th>
|
<th class="col-name">Name</th>
|
||||||
<th class="num">Standard ABV</th>
|
<th class="num col-abv" title="Standard ABV"><span class="long">Standard ABV</span><span class="short">Std</span></th>
|
||||||
<th class="num">Legacy ABV</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">Overstated by</th>
|
<th class="num col-abv" title="Overstated by"><span class="long">Overstated by</span><span class="short">Over</span></th>
|
||||||
<th>Finished</th>
|
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in abv_rows %}
|
{% for row in abv_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ row.beer_run_id }}</td>
|
<td class="col-run mono">{{ row.beer_run_id }}</td>
|
||||||
<td>{{ row.name }}</td>
|
<td class="col-name">{{ row.name }}</td>
|
||||||
<td class="num">{{ '%.2f' % row.abv }}%</td>
|
<td class="col-abv num">{{ '%.2f' % row.abv }}%</td>
|
||||||
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
<td class="col-abv num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
||||||
<td class="num">
|
<td class="col-abv num">
|
||||||
{% if row.delta > 0 %}
|
{% if row.delta > 0 %}
|
||||||
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
<td class="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -106,7 +106,15 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
|
|||||||
# wrong number.
|
# wrong number.
|
||||||
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
|
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
|
||||||
if saved is None:
|
if saved is None:
|
||||||
saved = dict(readings[-1]) if readings else {"date": "", "sg": None}
|
# The reading is not in the run. Advising about the newest
|
||||||
|
# reading instead would describe the wrong number, and the
|
||||||
|
# conditional write would then refuse it anyway - so it is
|
||||||
|
# better to say so plainly and stop.
|
||||||
|
log.info(
|
||||||
|
"no advice for reading %s: it is not one of the run's readings",
|
||||||
|
reading_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
latest = dict(saved)
|
latest = dict(saved)
|
||||||
latest["_id"] = reading_id
|
latest["_id"] = reading_id
|
||||||
|
|
||||||
@@ -119,6 +127,11 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
|
|||||||
# cannot overwrite a correction with advice about the old value.
|
# cannot overwrite a correction with advice about the old value.
|
||||||
advice_sg = latest.get("sg")
|
advice_sg = latest.get("sg")
|
||||||
advice_token = latest.get("advice_token")
|
advice_token = latest.get("advice_token")
|
||||||
|
# The comment is part of what the advice describes, so it is a
|
||||||
|
# second, independent guard on the write. The token covers every
|
||||||
|
# edit the application makes; this also covers a document edited
|
||||||
|
# outside it.
|
||||||
|
advice_comment = latest.get("comment") or ""
|
||||||
|
|
||||||
def write(read_id: str, ok: bool, text: str) -> None:
|
def write(read_id: str, ok: bool, text: str) -> None:
|
||||||
# The client is SHARED, not built per reading. A MongoClient
|
# The client is SHARED, not built per reading. A MongoClient
|
||||||
@@ -137,6 +150,7 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
|
|||||||
read_id, text, ok,
|
read_id, text, ok,
|
||||||
expected_sg=advice_sg,
|
expected_sg=advice_sg,
|
||||||
expected_token=advice_token,
|
expected_token=advice_token,
|
||||||
|
expected_comment=advice_comment,
|
||||||
)
|
)
|
||||||
if not written:
|
if not written:
|
||||||
# The reading was corrected while the advice was being
|
# The reading was corrected while the advice was being
|
||||||
@@ -145,8 +159,8 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
|
|||||||
# reading will land in its own turn.
|
# reading will land in its own turn.
|
||||||
log.info(
|
log.info(
|
||||||
"the advice for reading %s was not stored: the reading "
|
"the advice for reading %s was not stored: the reading "
|
||||||
"has been edited since the advice was generated, so the "
|
"has been edited since the advice was generated, so its "
|
||||||
"advice no longer matches it",
|
"editing token or comment no longer matches the advice",
|
||||||
read_id,
|
read_id,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - never let the thread die noisily
|
except Exception: # noqa: BLE001 - never let the thread die noisily
|
||||||
@@ -338,6 +352,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 +386,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
|
||||||
|
|||||||
@@ -714,3 +714,15 @@ def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
|
|||||||
assert len(entry["curve"]) == 2
|
assert len(entry["curve"]) == 2
|
||||||
assert entry["curve"][0]["progress"] == 0.0
|
assert entry["curve"][0]["progress"] == 0.0
|
||||||
assert entry["curve"][1]["progress"] == 1.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", "")
|
||||||
|
|||||||
@@ -1024,6 +1024,167 @@ def test_the_advice_in_the_table_is_a_popout_not_the_text(client, repo):
|
|||||||
assert "Warm it to 21C and wait." in body
|
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_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 '<span class="long">Brewing advice</span>' in body
|
||||||
|
assert '<span class="short">Advice</span>' in body
|
||||||
|
|
||||||
|
# The button has both forms.
|
||||||
|
assert '<span class="long">Read the advice</span>' in body
|
||||||
|
assert '<span class="short">Advice</span>' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_advice_cannot_overwrite_a_comment_only_correction(repo):
|
||||||
|
"""A comment-only correction still invalidates the old advice.
|
||||||
|
|
||||||
|
The gravity alone is not enough to tell two generations apart - a
|
||||||
|
comment-only correction leaves the gravity unchanged. The editing
|
||||||
|
token is what tells them apart, because every edit writes a fresh
|
||||||
|
one.
|
||||||
|
"""
|
||||||
|
repo.readings.insert_one({
|
||||||
|
"_id": "readingone",
|
||||||
|
"beer_run_id": "1",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"sg": "1040",
|
||||||
|
"comment": "original comment",
|
||||||
|
"final_reading": "False",
|
||||||
|
"advice_token": "token-one",
|
||||||
|
})
|
||||||
|
|
||||||
|
# The slow thread holds the token it was given.
|
||||||
|
stale_token = "token-one"
|
||||||
|
|
||||||
|
# The brewer corrects only the comment.
|
||||||
|
repo.readings.update_one(
|
||||||
|
{"_id": "readingone"},
|
||||||
|
{"$set": {"comment": "corrected comment", "advice_token": "token-two"},
|
||||||
|
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The fresh advice lands first, carrying the new token.
|
||||||
|
assert repo.set_reading_advice(
|
||||||
|
"readingone", "FRESH ADVICE", ok=True, expected_token="token-two") == 1
|
||||||
|
|
||||||
|
# The slow thread lands afterwards, carrying the old token. It must
|
||||||
|
# be refused.
|
||||||
|
assert repo.set_reading_advice(
|
||||||
|
"readingone", "STALE ADVICE", ok=True, expected_token=stale_token) == 0
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"_id": "readingone"})
|
||||||
|
assert doc["advice"] == "FRESH ADVICE"
|
||||||
|
assert doc["comment"] == "corrected comment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_advice_cannot_overwrite_a_comment_edited_outside_the_app(repo):
|
||||||
|
"""The comment is a guard in its own right, not just the token.
|
||||||
|
|
||||||
|
The token covers every edit the application makes. A document
|
||||||
|
edited outside it - by a script, or by hand - keeps its empty
|
||||||
|
token, so a stale thread's write would still match on the gravity
|
||||||
|
alone. Matching the comment as well closes that.
|
||||||
|
"""
|
||||||
|
repo.readings.insert_one({
|
||||||
|
"_id": "outsideedit01",
|
||||||
|
"beer_run_id": "1",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"sg": "1040",
|
||||||
|
"comment": "original comment",
|
||||||
|
"final_reading": "False",
|
||||||
|
# No advice_token - a legacy reading.
|
||||||
|
})
|
||||||
|
|
||||||
|
# The advice is written for the original comment.
|
||||||
|
assert repo.set_reading_advice(
|
||||||
|
"outsideedit01", "FRESH ADVICE", ok=True,
|
||||||
|
expected_token="", expected_comment="original comment") == 1
|
||||||
|
|
||||||
|
# Somebody edits the comment directly in the database, leaving the
|
||||||
|
# token empty - so the token cannot tell the generations apart.
|
||||||
|
repo.readings.update_one(
|
||||||
|
{"_id": "outsideedit01"},
|
||||||
|
{"$set": {"comment": "edited outside the app"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The slow thread lands. It must be refused, even though the token
|
||||||
|
# still matches.
|
||||||
|
assert repo.set_reading_advice(
|
||||||
|
"outsideedit01", "STALE ADVICE", ok=True,
|
||||||
|
expected_token="", expected_comment="original comment") == 0
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"_id": "outsideedit01"})
|
||||||
|
assert doc["advice"] == "FRESH ADVICE"
|
||||||
|
assert doc["comment"] == "edited outside the app"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_pop_out_is_hidden_in_the_markup(client, repo):
|
||||||
|
"""The pop-out carries the `hidden` attribute.
|
||||||
|
|
||||||
|
A stale stylesheet - one that does not know about `.advice-pop` at
|
||||||
|
all - would otherwise leave the advice expanded inline in the table
|
||||||
|
cell, running down the column and off the screen. The `hidden`
|
||||||
|
attribute makes the markup fail-safe: the panel stays hidden until
|
||||||
|
the script opens it.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
assert 'class="advice-pop"' in body
|
||||||
|
assert 'hidden' in body.split('class="advice-pop"')[1][:60]
|
||||||
|
|
||||||
|
|
||||||
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"})
|
||||||
|
|||||||
Reference in New Issue
Block a user