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
+193
View File
@@ -0,0 +1,193 @@
{% extends 'base.html' %}
{% block title %}Brew Process — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Brew Process</h1>
<p class="page__sub">Record a gravity reading, then review where each brew is up to.</p>
</div>
</div>
<div class="grid grid--sidebar">
<!-- ------------------------------------------------ record a reading -->
<section class="card">
<div class="card__head">
<h2 class="card__title">New reading</h2>
<span class="card__hint">Gravity in points (1039 = 1.039)</span>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field-row">
<div class="field">
<label class="field__label" for="beer_run_id">Run number</label>
<input type="number" id="beer_run_id" name="beer_run_id"
min="1" step="1" required>
</div>
<div class="field">
<label class="field__label" for="Date">Reading date</label>
<input type="date" id="Date" name="Date" required>
</div>
</div>
<div class="field">
<label class="field__label" for="beer_run_type">Beer / cider name</label>
<input type="text" id="beer_run_type" name="beer_run_type"
maxlength="120" placeholder="e.g. Necatron">
<div class="field__hint">The name is shown on the run summary.</div>
</div>
<div class="field">
<label class="field__label" for="sg">Gravity reading (points)</label>
<input type="number" id="sg" name="sg" min="900" max="1300"
step="1" required placeholder="1045">
<div class="field__hint">Drop the leading "1." — 1045 means 1.045.</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="final_reading" value="y">
<span>This is the final reading (closes the run)</span>
</label>
</div>
<div class="field">
<label class="field__label" for="comment">Comment</label>
<textarea id="comment" name="comment" maxlength="2000"
placeholder="Hops in, bottling, kegging…"></textarea>
</div>
<button class="btn btn--block" type="submit">Save reading</button>
</form>
</div>
</section>
<!-- --------------------------------------------------- run summaries -->
<div class="stack">
<section class="card">
<div class="card__head">
<h2 class="card__title">In the fermenter</h2>
<span class="card__hint">{{ active_runs | length }} active</span>
</div>
<div class="card__body card__body--flush">
{% if active_runs %}
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th></th>
</tr>
</thead>
<tbody>
{% for run in active_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg }}</span>
<div class="faint tiny">{{ '%.3f' % (run.min_sg / 1000) }}</div>
{% else %}—{% endif %}
</td>
<td class="num">
{% if run.projected_abv is not none %}
{{ '%.2f' % run.projected_abv }}%
{% else %}
<span class="faint" title="Record the original gravity for this run to get an estimate">—</span>
{% endif %}
</td>
<td class="comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty__icon" aria-hidden="true">&#127866;</span>
Nothing in the fermenter right now.
</div>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Finished brews</h2>
<span class="card__hint">{{ completed_runs | length }} completed</span>
</div>
<div class="card__body card__body--flush">
{% if completed_runs %}
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
</tr>
</thead>
<tbody>
{% for run in completed_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
</td>
<td class="num">
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
</td>
<td class="num">
{% if run.abv is not none %}
<strong>{{ '%.2f' % run.abv }}%</strong>
{% else %}—{% endif %}
</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">No finished brews recorded yet.</div>
{% endif %}
</div>
</section>
</div>
</div>
{% endblock %}