# Brew Log 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. Flask app, Mongo backend, no build step for the front end. ## 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 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. ## The pages | Page | What it is for | |---|---| | `/login` | Sign in | | `/brews` | The brew process: record a reading, review every run | | `/active` | What is in the fermenter right now — tap, gravity, projected strength | | `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison | | `/analysis` | How consistently the brews ferment — attenuation, fermentation curves, brew timings | ### `/active` — the active beers page A card per brew that is still in the fermenter (a run with no reading marked final), showing: - **the tap number** it is on, - **the current gravity**, as a decimal reading with the point scale beneath, - **the projected alcohol**, worked out from the run's original gravity, - **how many days** it has been brewing, - a **sparkline** of the gravity so far, - **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 on the run (or take a second reading) and it appears. ### `/analysis` — how the brews ferment The page that turns the history into something you can brew better from: - **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 gives sixteen 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. A reading that is physically impossible — a final gravity below the original gravity by more than the wort itself could have — is excluded from the averages rather than silently distorting them, and the run is listed on the page so it can be corrected. ### Run metadata Some details cannot be derived from the readings, so they are stored per run in the `beer_runs` collection and edited on the run detail page: - the **canonical name** the brew is known by (overriding the drifting per-reading name), - the **tap number**, - the **original gravity**, - free-text **brew notes**. ### Populating the run metadata from the existing readings ```bash # dry run - prints what it would do, writes nothing python scripts/backfill_run_metadata.py # write it python scripts/backfill_run_metadata.py --apply ``` The script is idempotent and non-destructive: it only inserts into `beer_runs`, never touches `beer_data`, and never overwrites a name you have already set by hand (pass `--force` if you want it to). ## 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, 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 scripts/ operational scripts (the run-metadata backfill) ``` `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.