- 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
{{ stats.consistency.mean }}%, with a standard
deviation of {{ stats.consistency.stdev }} percentage
points and a total range of {{ stats.consistency.spread }}
diff --git a/src/flask/templates/history.html b/src/flask/templates/history.html
index 3e493dd..a6c1530 100644
--- a/src/flask/templates/history.html
+++ b/src/flask/templates/history.html
@@ -23,7 +23,13 @@
Strongest
diff --git a/src/flask/views/analysis.py b/src/flask/views/analysis.py
index 2b1011e..078e161 100644
--- a/src/flask/views/analysis.py
+++ b/src/flask/views/analysis.py
@@ -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),
},
)
diff --git a/src/flask/views/brews.py b/src/flask/views/brews.py
index e1c4d30..fdd6238 100644
--- a/src/flask/views/brews.py
+++ b/src/flask/views/brews.py
@@ -198,6 +198,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 = []
@@ -228,6 +232,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)
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index ebc712c..97c909d 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -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