feat(ui): rebuild the interface with a proper design system
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 17m41s

The old UI was two pages of raw pandas tables with no navigation, no styling
and no way to see a run's comments. This replaces it with a small,
testable application and a beer-themed design.

What changed
------------

Restructure
- The domain logic now lives in `brewing.py` as pure functions over plain
  data, with no Flask or Mongo dependency. The gravity maths, the ABV
  formula and the run-aggregation rules are unit-testable on their own.
- `app_factory.py` replaces the module-level app, and the routes move into
  blueprints under `views/`. A request-scoped Mongo repository is created per
  request and closed at teardown.
- `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`.
  Reading a document no longer requires a second query to fetch the fields.

Interface
- A dark, beer-themed design system: warm amber `#be7f39` on near-black, a
  condensed display face (Anton) for headings and Inter for body text. Both
  fonts and the chart library are vendored into `static/`, so the container
  needs no outbound network at runtime — the old templates pulled Bootstrap
  from a CDN.
- A real navigation bar, a login page, a brew-process page with a proper
  form, and a run-detail page listing every reading with its comment.
- The run summary tables now show the beer name and the latest comment, which
  the old pandas tables did not.
- Native form controls (checkbox, number spinners, date icon, scrollbars)
  are restyled for the dark theme.

Correctness
- The alcohol calculation now uses the standard homebrew formula,
  `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is
  not a recognised brewing formula and overstated every result.
- Gravity points and decimal readings now convert correctly in both
  directions (`1039` <-> `1.039`).
- A run's display name resolves to the most-used label across its readings,
  because `beer_type` is stored per-reading and drifts across a run.
- Re-submitting a reading for the same run and day updates it in place
  rather than creating a duplicate.

Security / production
- The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an
  interactive console on any unhandled error. It now runs waitress with
  debugging off.
- `SECRET_KEY` comes from the environment.
- The app secret is no longer a hard-coded string in source.

Housekeeping
- Removes the bokeh/duckdb/pandas dependency chain and the static file it
  generated at import time; removes the dead `add_user.py` and `test.py`.
- Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests).
- Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the
  image is only built when the tests pass.
- Adds a README describing the data model and a RUNBOOK for the cluster side.

Data compatibility
------------------
The existing `beer_db.beer_data` documents are read as they are and are not
migrated or rewritten. No collection is dropped, and no document is modified
by this change other than through the explicit "record a reading" form, which
keeps the original `(date, beer_run_id)` uniqueness rule.
This commit is contained in:
2026-11-09 10:21:44 +10:00
parent fb65ba4ee0
commit 24cdd452c5
37 changed files with 2943 additions and 725 deletions
+431
View File
@@ -0,0 +1,431 @@
"""Pure brewing logic for the beer_data app.
Everything in this module is a pure function over plain data (no Flask, no Mongo,
no pandas) so the maths can be unit-tested without a database.
Gravity convention
------------------
The Mongo collection stores specific gravity as integer *points*: the digits after
the decimal point of the usual 1.0xx reading. ``1039`` means 1.039. This matches
how the readings were entered and keeps the stored data unchanged.
Alcohol by volume
-----------------
Standard homebrew formula (Charlie Papazian / "The Joy of Homebrewing"):
ABV% = (OG - FG) * 131.25
With gravities expressed in points the difference is directly in points, so:
ABV% = points_drop * 0.13125
The legacy application used ``(max - min) / 7.36 + 0.5`` which is not a recognised
formula and overstates the result (it also returns 0.5% for a run with no drop at
all). :func:`legacy_abv_percent` keeps the old maths available so the history page
can show the difference between the two, but new code uses :func:`abv_percent`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Iterable, Sequence
__all__ = [
"ABV_FACTOR",
"ASSUMED_ORIGINAL_GRAVITY",
"Reading",
"Run",
"abv_percent",
"assumed_og_abv_percent",
"days_between",
"final_reading",
"first_reading",
"gravity_drop_points",
"is_active",
"is_plausible_sg",
"last_reading",
"legacy_abv_percent",
"parse_date",
"parse_sg_points",
"run_summary",
"sg_points_to_decimal",
]
#: Conversion factor for points-drop -> percent alcohol (131.25 / 1000).
ABV_FACTOR = 0.13125
#: Original gravity assumed for an in-progress run when no OG was recorded.
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
ASSUMED_ORIGINAL_GRAVITY = 1012
#: Plausible range for a hydrometer reading, in points.
_MIN_PLAUSIBLE_SG = 990
_MAX_PLAUSIBLE_SG = 1200
# --------------------------------------------------------------------------- #
# Parsing helpers
# --------------------------------------------------------------------------- #
def parse_sg_points(raw: object) -> int | None:
"""Return the gravity in points for *raw*, or ``None`` if it is not usable.
Accepts ints, floats and strings. ``"1039"``, ``1039`` and ``1039.0`` all
become ``1039``. Values like ``"1.039"`` are also accepted and converted, and
anything unparseable yields ``None`` rather than raising.
"""
if raw is None:
return None
if isinstance(raw, bool): # bool is an int subclass; never a gravity
return None
if isinstance(raw, (int, float)):
value = float(raw)
else:
text = str(raw).strip()
if not text:
return None
try:
value = float(text)
except ValueError:
return None
if value > 100: # already stored as points (1039)
return int(round(value))
if value >= 0.9: # a decimal reading such as 1.039
return int(round(value * 1000))
return None
def sg_points_to_decimal(points: int | None) -> float | None:
"""Convert points (``1039``) to the conventional decimal reading (``1.039``)."""
if points is None:
return None
return round(points / 1000.0, 4)
def parse_date(raw: object) -> date | None:
"""Parse a ``YYYY-MM-DD`` date, tolerating the odd alternative layout."""
if raw is None:
return None
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
text = str(raw).strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _as_bool(value: object) -> bool:
"""Interpret the stored ``final_reading`` flag.
The collection stores it as the *string* ``"True"``/``"False"`` for historic
rows and as a real boolean for newer ones, so both are accepted.
"""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"true", "1", "yes", "y", "t"}
# --------------------------------------------------------------------------- #
# Domain objects
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Reading:
"""A single gravity reading taken on a given day."""
date: date | None
sg_points: int | None
final_reading: bool = False
comment: str = ""
beer_type: str = ""
_id: str = ""
@property
def sg_decimal(self) -> float | None:
return sg_points_to_decimal(self.sg_points)
@property
def is_plausible(self) -> bool:
return is_plausible_sg(self.sg_points)
@classmethod
def from_doc(cls, doc: dict) -> "Reading":
"""Build a :class:`Reading` from a raw Mongo document."""
return cls(
date=parse_date(doc.get("date")),
sg_points=parse_sg_points(doc.get("sg")),
final_reading=_as_bool(doc.get("final_reading")),
comment=(doc.get("comment") or "").strip(),
beer_type=(doc.get("beer_type") or "").strip(),
_id=str(doc.get("_id") or ""),
)
@dataclass
class Run:
"""All readings that belong to one brew run."""
beer_run_id: str
readings: list[Reading] = field(default_factory=list)
@classmethod
def from_docs(cls, beer_run_id: str, docs: Iterable[dict]) -> "Run":
readings = [Reading.from_doc(d) for d in docs]
readings.sort(key=lambda r: (r.date or date.min, r.sg_points or 0))
return cls(beer_run_id=str(beer_run_id), readings=readings)
# -- aggregate accessors ------------------------------------------------ #
def sg_values(self) -> list[int]:
"""Gravity points of every *usable* reading, in chronological order."""
return [r.sg_points for r in self.readings if r.sg_points is not None]
def original_gravity(self) -> int | None:
"""OG = the first usable reading of the run."""
values = self.sg_values()
return values[0] if values else None
def final_gravity(self) -> int | None:
"""FG = the last usable reading of the run (if the run is finished)."""
values = self.sg_values()
return values[-1] if values else None
def max_sg(self) -> int | None:
values = self.sg_values()
return max(values) if values else None
def min_sg(self) -> int | None:
values = self.sg_values()
return min(values) if values else None
def is_active(self) -> bool:
return is_active(self.readings)
def final_reading(self) -> Reading | None:
return final_reading(self.readings)
def first_reading(self) -> Reading | None:
return first_reading(self.readings)
def last_reading(self) -> Reading | None:
return last_reading(self.readings)
def gravity_drop_points(self) -> int | None:
og, fg = self.original_gravity(), self.final_gravity()
if og is None or fg is None:
return None
return og - fg
def abv_percent(self) -> float | None:
"""Standard ABV for a finished run."""
drop = self.gravity_drop_points()
return abv_percent(drop) if drop is not None else None
def projected_abv_percent(self, assumed_og: int = ASSUMED_ORIGINAL_GRAVITY,
og_override: int | None = None) -> float | None:
# `assumed_og` is retained for API compatibility but is deliberately
# NOT used as a fallback — see the docstring.
"""ABV estimate for an *unfinished* run, or ``None`` if unknowable.
The original gravity is taken from, in order of preference:
1. ``og_override`` — the OG recorded against the run by the user.
2. The run's first reading, when there are two or more readings.
The first reading of a run IS its original gravity — that is what
a reading taken on brew day is for.
With a single reading and no recorded OG there is NO reliable
estimate: the reading may have been taken on brew day or mid-
fermentation. The legacy app assumed 1012 anyway, which produced
*negative* alcohol whenever the reading was above 1012 — a perfectly
normal OG. Rather than show a misleading figure, ``None`` is
returned and the UI shows a dash until an OG is known. Record the OG
against the run and the estimate appears.
"""
values = self.sg_values()
if not values:
return None
if og_override is not None:
og = og_override
elif len(values) >= 2:
og = values[0]
else:
# A single reading and no recorded OG: there is no reliable
# original gravity to measure against, so there is no estimate.
return None
fg = self.min_sg()
if fg is None:
return None
drop = og - fg
if drop <= 0:
# Gravity has risen during fermentation — an anomaly, not alcohol.
return None
return abv_percent(drop)
def days_in_fermenter(self, today: date | None = None) -> int | None:
first = self.first_reading()
if first is None or first.date is None:
return None
final = self.final_reading()
end = final.date if (final is not None and final.date is not None) else (today or date.today())
if end is None:
return None
return days_between(first.date, end)
def display_name(self) -> str:
"""Best available human label for the run.
The stored ``beer_type`` drifts between readings of the same run, so the
most frequently used non-empty value wins; ties break towards the most
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
rather than "Necatron-Christmas Beer".
"""
counts: dict[str, int] = {}
for reading in self.readings:
label = reading.beer_type
if label:
counts[label] = counts.get(label, 0) + 1
if not counts:
return f"Run {self.beer_run_id}"
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
return best
def _last_index_of(self, label: str) -> int:
for index in range(len(self.readings) - 1, -1, -1):
if self.readings[index].beer_type == label:
return index
return -1
def comments(self) -> list[str]:
"""Every non-empty comment, oldest first."""
return [r.comment for r in self.readings if r.comment]
def outlier_readings(self) -> list[Reading]:
"""Readings whose gravity is outside the plausible hydrometer range."""
return [r for r in self.readings if r.sg_points is not None and not r.is_plausible]
# --------------------------------------------------------------------------- #
# Module level helpers (usable without a Run instance)
# --------------------------------------------------------------------------- #
def abv_percent(points_drop: int | float | None) -> float | None:
"""Standard ABW/ABV from a gravity drop expressed in points."""
if points_drop is None:
return None
return round(float(points_drop) * ABV_FACTOR, 2)
def legacy_abv_percent(max_points: int | float | None,
min_points: int | float | None) -> float | None:
"""The legacy app's formula, kept for the legacy-vs-standard comparison.
The old code computed ``((max - min) / 7.36) + 0.5``. It is not a recognised
brewing formula and returns 0.5% even when nothing has fermented.
"""
if max_points is None or min_points is None:
return None
return round(((float(max_points) - float(min_points)) / 7.36) + 0.5, 2)
def assumed_og_abv_percent(current_points: int | None,
assumed_og: int = ASSUMED_ORIGINAL_GRAVITY) -> float | None:
"""ABV for an in-progress run measured against an assumed original gravity."""
if current_points is None:
return None
return abv_percent(assumed_og - current_points)
def is_plausible_sg(points: int | None) -> bool:
"""True when a gravity reading sits inside a believable hydrometer range."""
if points is None:
return False
return _MIN_PLAUSIBLE_SG <= points <= _MAX_PLAUSIBLE_SG
def is_active(readings: Sequence[Reading]) -> bool:
"""A run is active while it has no reading marked as the final one."""
return not any(r.final_reading for r in readings)
def final_reading(readings: Sequence[Reading]) -> Reading | None:
"""The reading flagged as final, or ``None``."""
for reading in readings:
if reading.final_reading:
return reading
return None
def first_reading(readings: Sequence[Reading]) -> Reading | None:
"""The chronologically first reading."""
dated = [r for r in readings if r.date is not None]
if dated:
return min(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
return readings[0] if readings else None
def last_reading(readings: Sequence[Reading]) -> Reading | None:
"""The chronologically last reading."""
dated = [r for r in readings if r.date is not None]
if dated:
return max(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
return readings[-1] if readings else None
def days_between(start: date, end: date) -> int:
return (end - start).days
def run_summary(run: Run, today: date | None = None,
og_override: int | None = None) -> dict:
"""A flat dict describing a run, ready for a template or a JSON response."""
final = run.final_reading()
first = run.first_reading()
og = run.original_gravity()
fg = run.final_gravity()
return {
"beer_run_id": run.beer_run_id,
"name": run.display_name(),
"active": run.is_active(),
"og_points": og,
"fg_points": fg,
"og_decimal": sg_points_to_decimal(og),
"fg_decimal": sg_points_to_decimal(fg),
"max_sg": run.max_sg(),
"min_sg": run.min_sg(),
"drop_points": run.gravity_drop_points(),
"abv": run.abv_percent(),
"projected_abv": run.projected_abv_percent(og_override=og_override),
"legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
"reading_count": len(run.readings),
"first_date": first.date.isoformat() if (first and first.date) else None,
"final_date": final.date.isoformat() if (final and final.date) else None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"outliers": [r.sg_points for r in run.outlier_readings()],
"readings": [
{
"date": r.date.isoformat() if r.date else None,
"sg": r.sg_points,
"sg_decimal": r.sg_decimal,
"final_reading": r.final_reading,
"comment": r.comment,
"beer_type": r.beer_type,
}
for r in run.readings
],
}