feat(analysis): attenuation, fermentation curves and brew timings

Adds the analysis page the old app never had: how consistently the brews
ferment, and how long each one takes.

What it shows
-------------
- **Attenuation by brew** - what fraction of the original gravity was
  fermented away, for every finished brew.
- **Fermentation curves** - the typical shape of a fermentation, drawn as a
  median curve with the min-max envelope behind it. Plotting every brew
  individually gave 16 near-identical lines and an unreadable legend.
- **Every finished brew** - OG, FG, ABV, attenuation, days in the fermenter,
  days to reach 1.010, and the finish date.
- **Readings that need a second look** - readings outside the plausible
  hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
  average time to 1.010.

Data quality
------------
One live run (run 12) has a final gravity of 900, which worked out as 338%
attenuation and dragged the average to 103.2%. That is not a real result,
so:

- Attenuation is rejected when the final gravity is not physically possible
  for the original gravity.
- Runs that fail that check are EXCLUDED from the averages rather than
  silently distorting them, and are listed on the page as needing review.
- The average now reads 87.5% +/- 7.4 percentage points across 16 brews.

Chart bugs found while building this
------------------------------------
- **The gravity charts had their y axis reversed.** With `reverse: true`
  the high reading sits at the BOTTOM, so the fermentation curve climbed
  left to right - it read as gravity *rising* as the beer fermented. A
  brewer reads a fermentation as a descending line. The point-scale run
  chart on the run detail page had the same bug. Both are fixed.
- **The curve chart's x axis was treated as categorical.** The x values are
  numeric (0-100), and Chart.js defaults a line chart's x axis to
  category, which collapsed every point onto index 0 and rendered the
  curve as a sliver at the origin. The axis is now explicitly linear.
- **The curve was interpolated by exact key lookup**, so it only worked when
  every brew happened to be read at the same progress points. It now
  interpolates between a brew's actual readings, and the curve carries 21
  points across the full range.
- **Long beer names were clipped** at the left edge of the attenuation
  chart ("...x crying out loud"). Names are trimmed to 18 characters with
  an ellipsis; the tooltip and the table carry the full name.

Tests
-----
127 (up from 105). The two chart regressions are covered by reading
`charts.js` and asserting the gravity axis is not reversed and that long
names are trimmed - these are exactly the kind of change that silently
reverts, and they are invisible to a screenshot test.
This commit is contained in:
2026-11-09 13:11:23 +10:00
parent 0785c11790
commit e1a9a7384f
7 changed files with 1023 additions and 3 deletions
+62
View File
@@ -639,3 +639,65 @@ def test_month_range_covers_a_year_boundary():
"2025-11", "2025-12", "2026-01", "2026-02",
]
assert _month_range("2026-05", "2026-05") == ["2026-05"]
# ------------------------------------------------------------- deep analysis #
def test_the_analysis_page_renders(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
])
response = logged_in.get("/analysis")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Ginger Beer" in body
# The attenuation figures and the summary sentence.
assert "80.0%" in body
assert "attenuation" in body.lower()
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
"""An unfinished brew has no final reading, so it is not comparable."""
seed_readings(repo, [
("1", "Finished", "1050", "2026-01-01", False, ""),
("1", "Finished", "1010", "2026-01-20", True, ""),
("2", "Still Going", "1045", "2026-02-01", False, ""),
])
body = logged_in.get("/analysis").data.decode()
assert "Finished" in body
assert "Still Going" not in body
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
response = logged_in.get("/analysis")
assert response.status_code == 200
assert b"No finished brews" in response.data
def test_the_analysis_page_is_protected(client):
response = client.get("/analysis")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
"""The charts fetch the curve data from the API."""
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
])
payload = logged_in.get("/api/runs").get_json()
entry = payload[0]
assert entry["attenuation"] == 80.0
assert len(entry["curve"]) == 2
assert entry["curve"][0]["progress"] == 0.0
assert entry["curve"][1]["progress"] == 1.0