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
+43 -19
View File
@@ -1,26 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Brew Log{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/logo.svg') }}" />
</head>
<body>
{% if logged_in %}
<header class="topbar">
<div class="topbar__inner">
<a class="brand" href="{{ url_for('brews.index') }}">
<img class="brand__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="26" height="26">
<span class="brand__text">Brew Log</span>
</a>
<title>{% block title %} Let there Be Pool Data {% endblock %}</title>
<nav class="nav" aria-label="Main">
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
href="{{ url_for('brews.index') }}">Brew Process</a>
</nav>
{{ bootstrap.load_css() }}
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span class="topbar__user">{{ session.get('username', '') }}</span>
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
</form>
</div>
</header>
{% endif %}
<style>
body {
background: #e8f1f9;
}
</style>
</head>
<body>
<!-- this is a base template using Bootstrap-Flask
https://bootstrap-flask.readthedocs.io/ -->
<main class="{% block main_class %}page{% endblock %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash flash--{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %} {% endblock %}
{% block content %}{% endblock %}
</main>
<!-- you can delete the next line if you're not using any Bootstrap JS -->
{{ bootstrap.load_js() }}
</body>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>