Compare commits

...
18 Commits
Author SHA1 Message Date
hermes 8e4770f870 fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
Build and Push Image / Build and push image (push) Successful in 3m33s
fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
2026-11-09 22:41:44 +10:00
hermes fb1088ceec fix(ui): make the advice pop-out fail-safe against a stale stylesheet
The screenshot from Firefox showed the advice expanded inline in the
table cell, running down the column and off the screen, with the long
and short labels both showing ("BREWING ADVICEADVICE").  The page had
the new markup and Firefox was serving a stale stylesheet.

Two fixes.

The static files now send Cache-Control: no-store.  They sent
no-cache, which means "revalidate" - and Firefox was reusing them
without revalidating.  no-store is the only one that means "do not
keep this".  The HTML pages already sent no-store.

The pop-out carries the hidden attribute, and the script removes it
when the panel opens and puts it back when it closes.  That makes the
MARKUP fail-safe rather than the stylesheet: a stylesheet that does
not know about .advice-pop at all still leaves the panel hidden, so
the advice can never appear expanded inside the cell.

Verified by removing all eight .advice-pop rules from the live
stylesheet - the panel stays hidden, 0x0, and the advice is not
rendered.  Before this it would have rendered inline.

The button still opens it on tap and on hover, and the close button
still closes it.  The desktop is unchanged: the same full headings and
the same hover pop-out.

195 tests pass.
2026-11-09 22:41:20 +10:00
hermes cd45cc2f17 fix(advice): guard the advice write on the comment as well as the token (#21)
Build and Push Image / Build and push image (push) Successful in 3m10s
fix(advice): guard the advice write on the comment as well as the token (#21)
2026-11-09 22:27:01 +10:00
hermes e603f23928 fix(advice): guard the advice write on the comment as well as the token
The editing token catches every edit the application makes, because
upsert_reading writes a fresh token on every write.  A document edited
outside the application - by a script, or by hand - keeps its empty
token, so a stale thread's write would still match on the gravity alone
and could put advice about the old comment back.

The advice is generated from the comment as well as the gravity, so the
comment is now a second, independent guard on the write.  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 - two of the live readings are padded - so the match is a
stripped, anchored regex rather than an exact one.

Also: the log message said the reading 'no longer holds the gravity it
was written for', but the guard is the token and the comment.  It now
says what actually happened.

194 tests pass.
2026-11-09 22:26:29 +10:00
hermes a38febaca8 fix(advice): return matched_count, and do not advise about the wrong reading (#20)
Build and Push Image / Build and push image (push) Successful in 3m15s
fix(advice): return matched_count, and do not advise about the wrong reading (#20)
2026-11-09 22:16:46 +10:00
hermes ef0d665b78 fix(advice): return matched_count, and do not advise about the wrong reading
set_reading_advice returned modified_count, which is 0 both when the
guard refused the write and when the write matched but the text was
already identical.  The caller logs "the advice was not stored" on 0,
so it has to mean "the guard refused it".  It now returns
matched_count.

_start_advice fell back to the newest reading when the reading it was
asked about was not in the run.  That described the wrong number, and
the conditional write would refuse it anyway - so it now says so and
stops.

The editing token is the primary guard on the write, not the gravity.
Every edit writes a fresh token, so a comment-only correction - which
leaves the gravity unchanged - is caught too.  Verified against live
Mongo with the real repository code: the fresh advice lands, the slow
stale thread is refused, and the brewer's comment is untouched.

193 tests pass.
2026-11-09 22:16:29 +10:00
hermes 81ebdf3c76 fix(ui): stop the pages being cached, and the labels duplicating (#19)
Build and Push Image / Build and push image (push) Successful in 3m29s
fix(ui): stop the pages being cached, and the labels duplicating (#19)
2026-11-09 22:06:58 +10:00
hermes e473850aac fix(ui): stop the pages being cached, and the labels duplicating
Firefox served a cached copy of the brewing page after the advice
became a button and a pop-out, so the phone kept showing the old
inline advice - a long paragraph in the table cell, with the button
label reading "Read the adviceAdvice".

Two separate causes:

The pages had no cache headers, so Firefox was free to reuse them.
They now send Cache-Control: no-store, must-revalidate.

The rule that hides the short label was scoped to `table.data .short`,
so it did not apply to the advice button's label.  A stale stylesheet
then showed both forms at once.  The rule is now on `.short` alone, so
it applies wherever the pattern is used - a table heading, the advice
button, the Active Beers page - and only ever one form can show.

Verified at 390px and 1280px: the phone shows "Advice", the desktop
shows "Read the advice" and "BREWING ADVICE", and no element ever
shows both.

192 tests pass.
2026-11-09 22:06:42 +10:00
hermes 571e7cb8c8 fix(ui): keep the table columns readable on a phone (#18)
Build and Push Image / Build and push image (push) Successful in 3m20s
fix(ui): keep the table columns readable on a phone (#18)
2026-11-09 21:48:20 +10:00
hermes 27b2611a20 fix(ui): keep the table columns readable on a phone
The tables were squeezed to fit the screen, and with seven to nine
columns on a 390px phone every heading and every value was truncated to
a few characters - a gravity of "1.0..." and a date of "202..." are
worse than useless, and the beer name was painted over the gravity
reading because the cell was allowed to overflow.

The tables keep their natural width and scroll sideways inside their
card, which is what a table does.  The advice is a small button so the
column that matters is narrow, and the headings have a short form for
a phone ("Brewing advice" -> "Advice", "Attenuation" -> "Att.").

The secondary columns - the drop, the legacy ABV and the days - go away
on a phone; the details are still on the run's own page.

A name and a comment get a sensible minimum width and wrap on a word
boundary rather than one word per line.

The desktop is untouched: the same full headings, the same 806px table,
and the pop-out still opens on hover.
2026-11-09 21:48:07 +10:00
hermes 81b2a17b37 fix(ui): dimmed backdrop and a bigger close button on the advice panel (#17)
Build and Push Image / Build and push image (push) Successful in 3m39s
fix(ui): dimmed backdrop and a bigger close button on the advice panel (#17)
2026-11-09 21:14:55 +10:00
hermes 2600622af8 fix(ui): dimmed backdrop and a bigger close button on the advice panel
The panel read as floating in the page rather than sitting over it, and
the close button was a 26px target - too small to hit reliably on a
phone.

The backdrop is a large spread shadow rather than a pseudo element,
which would paint behind the panel's own background.

Also: Escape closes the panel, clicking away closes it, and the button
is a real button so it works with a keyboard.
2026-11-09 21:14:33 +10:00
hermes af28ecbd1b fix(ui): mobile pop-out for the advice, and tables that fit a phone (#16)
Build and Push Image / Build and push image (push) Successful in 12m4s
fix(ui): mobile pop-out for the advice, and tables that fit a phone (#16)
2026-11-09 20:58:06 +10:00
hermes 21fcdf485c test: the advice in the table is a pop-out, not the text 2026-11-09 20:57:34 +10:00
hermes 7dd39190f1 fix(ui): mobile pop-out for the advice, and tables that fit a phone
The brewing advice is several hundred characters.  In a table cell it
pushed the table past the width of a phone, so the column was clipped
off the right edge and the advice could not be read without sideways
scrolling.

The advice is now a compact trigger in the cell and the text is in a
floating panel that opens on hover (desktop) or on tap (phone).

The panel is position:fixed and anchored by the script.  Absolutely
positioned inside the cell it was clipped by the table's own scroll
container, and the advice was sliced in half by the card's edge.  It
flips above the trigger when there is no room below.

The tables now reflow into stacked cards on a narrow screen, with each
value labelled by its column name.  A seven column table does not fit a
phone, and it used to scroll sideways inside its wrapper, so the
columns on the right were off the screen.

Also: the header wraps onto two rows so the nav tabs are not clipped,
the point scale says 'points' rather than 'pts', and the reading count
on the beer card does not orphan its word.
2026-11-09 20:57:34 +10:00
hermes 55b6068b3b feat(advice): LLM brewing advice on every observation (#14)
Build and Push Image / Build and push image (push) Successful in 9m8s
feat(advice): LLM brewing advice on every observation (#14)
2026-11-09 20:23:32 +10:00
hermes c821a02d6e fix(charts): leave the suspect and active runs out of the bar charts (#15)
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(charts): leave the suspect and active runs out of the bar charts (#15)
2026-11-09 16:46:56 +10:00
hermes 794df0c12e fix(charts): leave the suspect and active runs out of the bar charts
The second review of PR #12 (after the suspect-run fix) found that the
two BAR charts were still plotting runs they should not have been, while
the curve chart and the tiles had been corrected.  The page was
contradicting itself.

The ABV chart plotted the suspect run
--------------------------------------
Run 12's impossible reading gives it an ABV of 18.64%.  The chart's y
axis went to 18.64, so the fifteen real bars were squashed into the
bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same
page showed the correct figure.  The chart now leaves the suspect runs
out.

The attenuation chart plotted the active runs
----------------------------------------------
An active run's attenuation is measured against its CURRENT gravity, so
a brew that has just started shows a 0% bar.  That is the absence of a
result, not a result, and it sat directly above the table of finished
brews.  The chart now leaves the active runs out.

Verified against the live data:

    ABV chart bars:        bars=15 max=8.53   (was 16 bars, max 18.64)
    attenuation chart bars: bars=15 min=76.2   (was 17 bars, min 0)

A suspect active run no longer vanishes
---------------------------------------
The API flags suspect runs over EVERY run, so the charts leave them out.
But the analysis page only looked at the finished ones, so an active run
with a bad reading would be dropped from the charts and never listed in
"Readings that need a second look" - it would disappear with no
explanation anywhere.  The analysis page now lists suspect active runs
too.

The figures say what they are based on
--------------------------------------
- The analysis prose counted the non-suspect runs, not the ones that
  actually contributed a figure to the mean.  It now counts the latter.
- The history page's "Average ABV — across finished brews" was averaging
  15 of 16 without saying so, while the analysis page said "16 brews (15
  counted)".  It now says "across 15 of 16 finished brews".
- `/api/runs/<run_id>` carries the `suspect` and `active` flags that
  `/api/runs` does.

148 tests (up from 142).
2026-11-09 16:46:30 +10:00
14 changed files with 1030 additions and 131 deletions
+25
View File
@@ -87,6 +87,31 @@ def create_app(config: dict | None = None) -> Flask:
if config:
app.config.update(config)
# The pages must not be cached by the browser.
# A phone held on to a cached copy of the brewing page and showed
# the old inline advice instead of the button and the pop-out, so
# the change was not visible until the cache was cleared by hand.
@app.after_request
def _no_store(response):
if response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
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)
# ------------------------------------------------------------------ #
+35 -2
View File
@@ -18,6 +18,7 @@ Two collections are used:
from __future__ import annotations
import logging
import re
import os
import threading
from typing import Any
@@ -232,7 +233,8 @@ class BeerRepository:
def set_reading_advice(self, reading_id: str, advice: str,
ok: bool = True,
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.
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.
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
# ARRAY match in Mongo, which is not what is wanted here.
result = self.readings.update_one(
@@ -296,7 +323,13 @@ class BeerRepository:
"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,
final: bool) -> int:
+302 -18
View File
@@ -451,7 +451,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
/* ---------------------------------------------------------------- tables */
.table-wrap { overflow-x: auto; }
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table.data {
width: 100%;
@@ -480,6 +483,148 @@ table.data tbody tr:hover { background: var(--bg-raised-2); }
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* 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.
The rule is on `.short` alone, not on `table.data .short`, so it
applies everywhere the pattern is used - in a table heading, in the
advice button, and on the Active Beers page. When only the table
was covered, the advice button could show both forms at once and
read "Read the adviceAdvice". */
.short { display: none; }
/* On a narrow screen the header wraps onto two rows: the brand and the
sign-out on the first, the nav tabs on the second where they get the
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) {
.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;
}
/* 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 {
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
}
/* ----------------------------------------------------------------- chips */
.chip {
@@ -595,8 +740,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__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; }
/* 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__value { font-size: 1.3rem; }
.brew-card__stat .stat__meta { margin-top: auto; }
.brew-card__spark { height: 46px; }
/* The "start a new beer" tile — fills the last grid slot and gives the
@@ -733,27 +888,156 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
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 {
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
on the run detail page, where there is room to read it. */
.advice-cell .advice {
margin-top: 0;
.advice-trigger {
display: inline-flex;
align-items: center;
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;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
/* The clamped text opens up on hover, so the whole thing is readable
without leaving the page. */
.advice-cell:hover .advice,
.advice-cell:focus-within .advice {
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
.advice-trigger:hover,
.advice-trigger:focus-visible {
background: var(--amber);
color: var(--bg);
}
.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;
/* 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 {
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. */
/* 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 {
display: block;
}
.advice-pop[hidden] {
display: none;
}
/* 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;
}
/* 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
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%);
}
}
+160
View File
@@ -56,11 +56,171 @@
}
}
/**
* 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;
}
/* 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");
trigger.setAttribute("aria-expanded", "true");
place();
}
function close() {
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");
}
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 init() {
focusFirstEmpty();
defaultDateFields();
wireToggles();
wireConfirms();
wireAdvicePopouts();
}
if (document.readyState === "loading") {
+16 -2
View File
@@ -184,8 +184,16 @@
// Only finished brews have a measured ABV. An unfinished run
// has a projection, not a measurement, so it is not plotted
// 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) {
return r.abv !== null && r.abv !== undefined;
return !r.suspect &&
r.abv !== null && r.abv !== undefined;
});
if (!completed.length) { return; }
@@ -305,8 +313,14 @@
// A run with an impossible reading has no attenuation, and
// is flagged as suspect. Both are excluded, so one bad
// 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) {
return !r.suspect &&
return !r.suspect && !r.active &&
r.attenuation !== null && r.attenuation !== undefined;
});
if (!done.length) { return; }
+1 -1
View File
@@ -44,7 +44,7 @@
<div class="brew-card__meta">
Run {{ run.beer_run_id }}
{% if run.first_date %} &middot; since {{ run.first_date }}{% endif %}
{% if run.reading_count == 1 %} &middot; 1 reading{% else %} &middot; {{ run.reading_count }} readings{% endif %}
{% if run.reading_count == 1 %} &middot; 1&nbsp;reading{% else %} &middot; {{ run.reading_count }}&nbsp;readings{% endif %}
</div>
</div>
{% if run.tap_number is not none %}
+27 -27
View File
@@ -99,43 +99,43 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th class="num">Attenuation</th>
<th class="num">Days</th>
<th class="num">To 1.010</th>
<th>Finished</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="num col-abv" title="Attenuation"><span class="long">Attenuation</span><span class="short">Att.</span></th>
<th class="num col-abv col-days">Days</th>
<th class="num col-abv" title="Days to reach 1.010"><span class="long">To 1.010</span><span class="short">1.010</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="num">
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="col-gravity num">
{% if row.og_points is not none %}
{{ '%.3f' % (row.og_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-gravity num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-abv num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="num">
<td class="col-abv num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
<td class="col-abv col-days num">{{ row.days if row.days is not none else '—' }}</td>
<td class="col-abv num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -155,21 +155,21 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th>Problem</th>
<th class="col-run">Run</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="col-comment">Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="mono">
<td class="col-run mono">
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td>
<td class="col-gravity num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="col-comment comment">{{ row.problem }}</td>
</tr>
{% endfor %}
</tbody>
@@ -186,7 +186,7 @@
</div>
<div class="card__body">
<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
deviation of <strong>{{ stats.consistency.stdev }} percentage
points</strong> and a total range of {{ stats.consistency.spread }}
+51 -32
View File
@@ -78,48 +78,67 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th>Brewing advice</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">Gravity</th>
<th class="num col-abv" title="Estimated alcohol by volume"><span class="long">Est. ABV</span><span class="short">ABV</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-advice" title="Brewing advice"><span class="long">Brewing advice</span><span class="short">Advice</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in active_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
<div class="faint tiny point-scale">{{ run.min_sg_point_scale }} points</div>
{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.projected_abv is not none %}
{{ '%.2f' % run.projected_abv }}%
{% else %}
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
{% endif %}
</td>
<td class="comment">
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="comment advice-cell">
<td class="advice-cell col-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">&#9998;</span>
<span class="advice-trigger__label">
{% if run.advice_ok %}
<span class="long">Read the advice</span><span class="short">Advice</span>
{% else %}
<span class="long">Advice failed</span><span class="short">Failed</span>
{% endif %}
</span>
</button>
<div class="advice-pop" role="tooltip" hidden>
<div class="advice-pop__head">
<span class="advice__label">Brewing advice</span>
<button type="button" class="advice-pop__close"
aria-label="Close">&times;</button>
</div>
<p class="advice-pop__text">{{ run.advice }}</p>
</div>
{% else %}
<span class="faint">Thinking…</span>
{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
@@ -148,41 +167,41 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in completed_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-gravity">
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.abv is not none %}
<strong>{{ '%.2f' % run.abv }}%</strong>
{% else %}—{% endif %}
</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
<td class="mono nowrap col-finished">{{ run.final_date or '—' }}</td>
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
+51 -45
View File
@@ -23,7 +23,13 @@
<div class="stat">
<div class="stat__label">Average 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 class="stat">
<div class="stat__label">Strongest</div>
@@ -78,21 +84,21 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Date</th>
<th class="num">Reading</th>
<th>Comment</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished">Date</th>
<th class="num col-gravity">Reading</th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
</tr>
</thead>
<tbody>
{% for row in outliers %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="mono nowrap">{{ row.date or '—' }}</td>
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="comment">{{ row.comment or '—' }}</td>
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-finished mono nowrap">{{ row.date or '—' }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="col-comment comment">{{ row.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -112,45 +118,45 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Status</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">Drop</th>
<th class="num">ABV</th>
<th class="num">Legacy</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished" title="Status"><span class="long">Status</span><span class="short">Stat</span></th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-drop">Drop</th>
<th class="num col-abv">ABV</th>
<th class="num col-legacy">Legacy</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in summaries %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td>
<td class="col-run mono">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="col-finished">
{% if run.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</td>
<td class="num">{{ run.og_points or '—' }}</td>
<td class="num">{{ run.fg_points or '—' }}</td>
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="num">
<td class="col-drop col-gravity num">{{ run.og_points or '—' }}</td>
<td class="col-gravity num">{{ run.fg_points or '—' }}</td>
<td class="col-gravity num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="col-abv num">
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
{% else %}—{% endif %}
</td>
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
<td class="col-legacy col-abv num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="col-finished mono nowrap">{{ run.final_date or '—' }}</td>
<td class="col-comment comment">
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
@@ -172,29 +178,29 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Standard ABV</th>
<th class="num">Legacy ABV</th>
<th class="num">Overstated by</th>
<th>Finished</th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-abv" title="Standard ABV"><span class="long">Standard ABV</span><span class="short">Std</span></th>
<th class="num col-legacy col-abv" title="Legacy ABV"><span class="long">Legacy ABV</span><span class="short">Old</span></th>
<th class="num col-abv" title="Overstated by"><span class="long">Overstated by</span><span class="short">Over</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in abv_rows %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="num">{{ '%.2f' % row.abv }}%</td>
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="num">
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-abv num">{{ '%.2f' % row.abv }}%</td>
<td class="col-abv num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="col-abv num">
{% if row.delta > 0 %}
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
{% else %}
<span class="faint">{{ '%.2f' % row.delta }}</span>
{% endif %}
</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
<td class="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
+17
View File
@@ -55,6 +55,15 @@ def deep():
suspect = implausible_runs(finished)
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 = []
for run in finished:
meta = metadata.get(run.beer_run_id, {})
@@ -111,6 +120,10 @@ def deep():
"suspect_count": len(suspect),
# How many runs the figures are actually based on.
"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)
if attenuations else None,
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
@@ -278,5 +291,9 @@ def history():
"avg_abv": avg_abv,
"best": best,
"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),
},
)
+22 -3
View File
@@ -106,7 +106,15 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
# wrong number.
saved = next((r for r in readings if r.get("_id") == str(reading_id)), 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["_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.
advice_sg = latest.get("sg")
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:
# 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,
expected_sg=advice_sg,
expected_token=advice_token,
expected_comment=advice_comment,
)
if not written:
# 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.
log.info(
"the advice for reading %s was not stored: the reading "
"has been edited since the advice was generated, so the "
"advice no longer matches it",
"has been edited since the advice was generated, so its "
"editing token or comment no longer matches the advice",
read_id,
)
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
# 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.
#
# 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)}
payload = []
@@ -368,6 +386,7 @@ def api_run(run_id: str):
# The same flag the list endpoint carries, so a consumer of the detail
# endpoint can tell an impossible run from a good one.
summary["suspect"] = bool(implausible_runs([run]))
summary["active"] = run.is_active()
return jsonify(summary)
+118
View File
@@ -508,3 +508,121 @@ def test_the_charts_fetch_the_run_payload_once():
"the run payload should be fetched once and shared"
)
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
+12
View File
@@ -714,3 +714,15 @@ def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
assert len(entry["curve"]) == 2
assert entry["curve"][0]["progress"] == 0.0
assert entry["curve"][1]["progress"] == 1.0
def test_the_pages_are_not_cached(client):
"""The HTML pages must not be cached by the browser.
A phone held on to a cached copy of the brewing page and kept
showing the old inline advice after the button and the pop-out had
been deployed, so the change was invisible until the cache was
cleared by hand.
"""
response = client.get("/login")
assert "no-store" in response.headers.get("Cache-Control", "")
+193 -1
View File
@@ -995,6 +995,196 @@ def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
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_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):
"""A generation failure reads differently from real advice."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
@@ -1008,7 +1198,9 @@ def test_a_failed_advice_is_marked_as_such(client, repo):
response = client.get("/brews")
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):