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.
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.
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.