feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the fermenter right now, and the per-run metadata (canonical name, tap number, original gravity, brew notes) that could not be derived from the readings. Why the metadata is needed -------------------------- The `beer_data` collection stores a beer name on *each reading*, and that name drifts within a run - run 4 was entered as "Necatron-Christmas Beer", then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was nowhere to record a tap number, a confirmed original gravity, or a note about the brew. The new `beer_runs` collection holds one document per run with exactly those details. It starts empty; `scripts/backfill_run_metadata.py` populates it from the existing readings. The script is idempotent, only ever inserts into `beer_runs`, never touches `beer_data`, and never overwrites a name you have already set by hand. The active beers page --------------------- A card per brew that is still in the fermenter, showing the tap number, the current gravity, the projected alcohol, how many days it has been brewing, a sparkline of the gravity so far, and the latest comment on a reading. A run with only one reading and no recorded original gravity has no reliable estimate, so the alcohol figure shows a dash rather than a misleading number - record the OG or take a second reading and it appears. The same applies to the existing reads, where the legacy app's assumed OG of 1012 produced *negative* alcohol for a perfectly normal OG. Also adds the history page: alcohol by brew, brews per month, every run in a table, a comparison of the standard ABV formula against the legacy one, and a list of readings that fall outside the plausible hydrometer range. Changes to the existing pages ----------------------------- - The brew-process page and the run detail page now show the canonical run name and the latest comment, and the run detail page lets you edit the metadata inline. - Gravity is displayed as a decimal reading (1.039) with the point scale beneath it (39 points), which is the way a brewer reads it. Labelling the stored `1039` as "1039 points" would have been wrong. - A run's display name now prefers the name on its *final* reading - the one written when the brew was finished, so the name the operator settled on - falling back to the most-used name. - `upsert_run_metadata` now clears a field when it is passed as `None`, so a name or tap number can be deleted. `upsert_run_metadata_partial` is the non-clearing variant, used by the backfill. Bugs found and fixed along the way ---------------------------------- - The chart script was included both in the base template and again by each page, so every chart was drawn twice. A test now asserts it is loaded exactly once per page. - The chart library was never referenced by the base template, so all charts were silently dead. That was fixed in the previous PR. Tests ----- 91 (up from 74). New coverage: the active page contents, the tap number, the projected alcohol, the "needs 2nd reading" case, metadata saving and clearing, the stored name overriding the drifting reading name, the next free tap number, the partial-update semantics, the history page, the outlier list, and the point-scale formatting. Data safety ----------- `beer_data` is untouched by this change. The backfill script only inserts into `beer_runs` and skips any run whose name has already been set by hand.
This commit is contained in:
@@ -170,6 +170,15 @@ def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
||||
# ----------------------------------------------------------------- misc #
|
||||
|
||||
|
||||
def test_the_chart_library_is_loaded_exactly_once(logged_in):
|
||||
"""Regression: the chart script was included both in the base template
|
||||
and again by each page, so every chart was drawn twice."""
|
||||
for path in ("/brews", "/active", "/history"):
|
||||
body = logged_in.get(path).data.decode()
|
||||
assert body.count("js/charts.js") == 1, path
|
||||
assert body.count("chart.umd.js") == 1, path
|
||||
|
||||
|
||||
def test_static_assets_are_served(client):
|
||||
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||
"/static/img/logo.svg"):
|
||||
@@ -303,3 +312,164 @@ def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||
importlib.reload(app_factory)
|
||||
|
||||
|
||||
# ------------------------------------------------- active beers and metadata #
|
||||
|
||||
def test_active_page_shows_only_unfinished_runs(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Finished Beer", "1050", "2026-01-01", False, ""),
|
||||
("1", "Finished Beer", "1010", "2026-01-20", True, ""),
|
||||
("2", "Still Brewing", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/active")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Still Brewing" in body
|
||||
assert "Finished Beer" not in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_tap_number(logged_in, repo):
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
repo.upsert_run_metadata("2", tap_number=4)
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Tap 4" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_projected_alcohol(logged_in, repo):
|
||||
"""With two readings the first is the OG, so the estimate is meaningful."""
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, ""),
|
||||
("2", "Still Brewing", "1020", "2026-02-10", False, ""),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
# 30 point drop = 3.94%
|
||||
assert "3.94%" in body
|
||||
|
||||
|
||||
def test_active_page_shows_a_dash_when_no_og_is_known(logged_in, repo):
|
||||
"""A single reading with no recorded OG has no reliable estimate."""
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "needs 2nd reading" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_latest_comment(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, "Hops in"),
|
||||
("2", "Still Brewing", "1030", "2026-02-10", False, "Almost there"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Almost there" in body
|
||||
|
||||
|
||||
def test_run_metadata_is_saved_and_surfaced(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
|
||||
logged_in.post("/runs/8", data={
|
||||
"name": "Canonical Name",
|
||||
"tap_number": "3",
|
||||
"og_points": "1050",
|
||||
"notes": "Starter was a 50F mess.",
|
||||
}, follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
assert meta["name"] == "Canonical Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["og_points"] == 1050
|
||||
assert meta["notes"] == "Starter was a 50F mess."
|
||||
|
||||
# The canonical name must appear on the brew-process page and the active page.
|
||||
assert "Canonical Name" in logged_in.get("/brews").data.decode()
|
||||
assert "Canonical Name" in logged_in.get("/active").data.decode()
|
||||
|
||||
|
||||
def test_run_metadata_can_be_cleared(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
repo.upsert_run_metadata("8", name="Set Name", tap_number=2)
|
||||
|
||||
logged_in.post("/runs/8", data={"name": "", "tap_number": "", "og_points": "", "notes": ""},
|
||||
follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
# The name was cleared, so it falls back to the reading-derived name.
|
||||
assert not meta.get("name")
|
||||
assert meta.get("tap_number") is None
|
||||
|
||||
|
||||
def test_the_stored_name_overrides_the_drifting_reading_name(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("9", "Old Name", "1050", "2026-03-01", False, ""),
|
||||
("9", "Newer Name", "1040", "2026-03-10", False, ""),
|
||||
])
|
||||
repo.upsert_run_metadata("9", name="The Name I Want")
|
||||
|
||||
body = logged_in.get("/brews").data.decode()
|
||||
assert "The Name I Want" in body
|
||||
|
||||
|
||||
def test_next_free_tap_number_skips_the_ones_in_use(repo):
|
||||
assert repo.next_free_tap_number() == 1
|
||||
|
||||
repo.upsert_run_metadata("1", tap_number=1)
|
||||
assert repo.next_free_tap_number() == 2
|
||||
|
||||
repo.upsert_run_metadata("2", tap_number=2)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
# A gap is filled before extending the range.
|
||||
repo.upsert_run_metadata("3", tap_number=4)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
|
||||
def test_history_page_shows_completed_brews(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/history")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "5.25%" in body # standard ABV for a 40 point drop
|
||||
assert "Legacy" in body # the comparison table is present
|
||||
|
||||
|
||||
def test_history_page_flags_an_implausible_reading(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "900", "2026-01-20", True, "typo?"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
assert "worth a second look" in body
|
||||
assert "typo?" in body
|
||||
|
||||
|
||||
def test_partial_metadata_update_leaves_other_fields_alone(repo):
|
||||
"""The backfill uses this: it must not wipe a tap number the operator
|
||||
has already set."""
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3, notes="Some notes")
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name="A Better Name")
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Better Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["notes"] == "Some notes"
|
||||
|
||||
|
||||
def test_partial_metadata_update_ignores_none(repo):
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3)
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name=None, tap_number=None)
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Name"
|
||||
assert meta["tap_number"] == 3
|
||||
|
||||
+40
-3
@@ -184,17 +184,36 @@ def test_run_is_active_until_a_final_reading_exists():
|
||||
assert final_reading(finished.readings) is not None
|
||||
|
||||
|
||||
def test_run_display_name_prefers_the_most_used_label():
|
||||
"""The stored beer_type drifts across readings; the most-used one wins."""
|
||||
def test_run_display_name_prefers_the_name_on_the_final_reading():
|
||||
"""The stored beer_type drifts across readings. The name on the final
|
||||
reading is the one the operator settled on."""
|
||||
run = Run.from_docs("4", [
|
||||
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
||||
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
|
||||
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
||||
])
|
||||
# All three are unique, so the most recent wins.
|
||||
assert run.display_name() == "Nocturna Christmas beer"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_most_used_label():
|
||||
"""Without a final reading, the most-used name wins, breaking ties
|
||||
towards the most recent."""
|
||||
run = Run.from_docs("5", [
|
||||
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
|
||||
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
|
||||
])
|
||||
# The final reading's name wins.
|
||||
assert run.display_name() == "Going Nowhere fast"
|
||||
|
||||
run2 = Run.from_docs("6", [
|
||||
_doc(date="2024-12-03", beer_type="Balter"),
|
||||
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
|
||||
_doc(date="2024-12-10", beer_type="Balter"),
|
||||
])
|
||||
# No final reading; "Balter" was used twice.
|
||||
assert run2.display_name() == "Balter"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_run_number():
|
||||
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||
assert run.display_name() == "Run 7"
|
||||
@@ -305,3 +324,21 @@ def test_run_summary_handles_an_empty_run():
|
||||
assert summary["abv"] is None
|
||||
assert summary["reading_count"] == 0
|
||||
assert summary["name"] == "Run 99"
|
||||
|
||||
|
||||
def test_point_scale_is_the_digits_after_the_decimal():
|
||||
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
|
||||
reading and must not be labelled as a point count."""
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1039"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_decimal"] == "1.039"
|
||||
assert summary["min_sg_point_scale"] == "39"
|
||||
|
||||
|
||||
def test_point_scale_for_a_round_reading():
|
||||
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_point_scale"] == "10"
|
||||
assert summary["min_sg_decimal"] == "1.010"
|
||||
|
||||
Reference in New Issue
Block a user