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
+101 -4
View File
@@ -1,7 +1,104 @@
# BEER SG data storer
# Brew Log
This container will store SG data for beer runs in Mongo and then use that data to calculate Alchohol Content of each beer
Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into
useful figures — alcohol by volume, days in the fermenter, gravity trends.
It's pretty loose and requires the Data Entry operator to know whats happening
Flask app, Mongo backend, no build step for the front end.
Luck for me... That's me!
## Running it locally
```bash
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
export MONGO_HOST=192.168.178.229
export MONGO_USER=root
export MONGO_PASS=...
export SECRET_KEY=anything-for-local-use
cd src/flask
python -m beer_data # dev server on :5000
# or, production-style:
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app
```
Then open <http://localhost:5000/> and sign in.
## Tests
```bash
. .venv/bin/activate
python -m pytest tests/ -q
```
The tests use `mongomock`, so they need no database and no network.
## Layout
```
src/flask/
beer_data.py entry point; exposes `app` and `create_app`
app_factory.py application factory, request-scoped Mongo repository
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
views/ blueprints: auth, brews, (later) analysis
mongo/repository.py Mongo access for the beer_data and beer_runs collections
templates/ Jinja templates
static/ CSS, JS, fonts, logo (all served locally — no CDN)
tests/ pytest suite
```
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
dependency, so the maths is unit-testable on its own.
## Data model
### `beer_data` — one document per gravity reading
| Field | Type | Notes |
|---|---|---|
| `_id` | string | 24 random characters |
| `beer_run_id` | string | The run this reading belongs to, e.g. `"20"` |
| `beer_type` | string | Beer/cider name. **Free text, per reading** — it drifts across a run |
| `sg` | string | Specific gravity in *points*: `1039` means 1.039 |
| `date` | string | `YYYY-MM-DD` |
| `final_reading` | string | `"True"` / `"False"` — marks the last reading of a run |
| `comment` | string | Free text |
The unique key is `(date, beer_run_id)`: re-submitting a reading for the same
run and day updates it in place rather than creating a duplicate.
A run is **active** while it has no reading with `final_reading == "True"`.
### `beer_runs` — one document per run (metadata)
Added alongside the readings to hold what cannot be derived from them: the
canonical beer name, the tap number, the recorded original gravity, and brew
notes. This collection is only ever inserted into, never written over by the
backfill.
## Alcohol by volume
Standard homebrew formula:
```
ABV% = (OG - FG) × 131.25
```
With gravity stored in points, the drop is already in points, so
`ABV% = points_drop × 0.13125`.
> The previous version of this app used `(max - min) / 7.36 + 0.5`, which is not
> a recognised brewing formula and overstated every result. The old figure is
> still computed by `brewing.legacy_abv_percent()` so the history page can show
> the difference between the two.
For a run still in the fermenter the figure is an **estimate**: it uses the
run's first reading as the original gravity. A run with a single reading and no
recorded OG has no reliable estimate, and the UI shows a dash rather than a
misleading number.
## Deployment
The image is built and pushed by `.gitea/workflows/build_push.yml` on push to
`master`. See [RUNBOOK.md](RUNBOOK.md) for the cluster side.