feat(ui): rebuild the interface with a proper design system
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:
@@ -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>
|
||||
|
||||
@@ -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">🍺</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 %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="margin-top:2rem">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<h1>{{ code }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Back to the brew log</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Login to Pool Data Tracker
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="log-form">
|
||||
{% if try_again %}
|
||||
<h4>Login Failed Please try Again</h4>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">WHOOOOOOO ARE YOU!</h1>
|
||||
|
||||
<p class="lead">If you know you know</p>
|
||||
|
||||
{{ render_form(form) }}
|
||||
|
||||
<p class="pt-5"><strong>{{ message }}</strong></p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!--end log form -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Sign in — Brew Log{% endblock %}
|
||||
{% block main_class %}page page--narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login">
|
||||
<div class="login__card">
|
||||
<img class="login__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="46" height="46">
|
||||
<h1 class="login__title">Brew Log</h1>
|
||||
<p class="login__sub">Track the gravity of every brew.</p>
|
||||
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username"
|
||||
autofocus required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">
|
||||
{{ summary.canonical_name }}
|
||||
{% if summary.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="page__sub">
|
||||
Run <span class="mono">{{ summary.beer_run_id }}</span>
|
||||
{% if summary.first_date %} · started {{ summary.first_date }}{% endif %}
|
||||
{% if summary.final_date %} · finished {{ summary.final_date }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--4" style="margin-bottom:1.25rem">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Original gravity</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.og_decimal %}
|
||||
<div class="stat__meta">{{ '%.3f' % summary.og_decimal }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">
|
||||
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
||||
</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.fg_decimal %}
|
||||
<div class="stat__meta">{{ '%.3f' % summary.fg_decimal }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Alcohol</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.abv is not none %}
|
||||
{{ '%.2f' % summary.abv }}%
|
||||
{% elif summary.projected_abv is not none %}
|
||||
{{ '%.2f' % summary.projected_abv }}%
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if summary.abv is not none %}measured{% else %}projected{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Days brewing</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings</h2>
|
||||
<span class="card__hint">{{ summary.reading_count }} total</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="reading-list">
|
||||
{% for reading in summary.readings %}
|
||||
<li class="reading-list__item">
|
||||
<span class="reading-list__date">{{ reading.date or '—' }}</span>
|
||||
<span class="reading-list__sg">
|
||||
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
|
||||
</span>
|
||||
<div class="reading-list__body">
|
||||
{% if reading.comment %}
|
||||
<div class="reading-list__comment">{{ reading.comment }}</div>
|
||||
{% else %}
|
||||
<div class="faint tiny">No comment</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if reading.final_reading %}
|
||||
<span class="chip chip--done">Final</span>
|
||||
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Data Input
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">Input Data you want to store</h1>
|
||||
{% if success %}
|
||||
<h3 class="pt-3 pb02">Thank you {{ updater_name | safe }}, Data Updated Successfully</h1>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table border=0>
|
||||
<tr>
|
||||
<td>
|
||||
{{ render_form(form) }}
|
||||
</td>
|
||||
<!-- <td>
|
||||
<div class="container">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<table border = 0>
|
||||
{% for row in list %}
|
||||
<table border=0>
|
||||
{% for key, value in row.items() %}
|
||||
<tr>
|
||||
<th> {{ key }} </th>
|
||||
<td> {{ value }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td> -->
|
||||
<!--<iframe style="width: 100vw; height: 40vh;" src="static/data_plot.html" frameborder="0" allowfullscreen>
|
||||
<!--<iframe style="width: 100vw;height: 80vh;position: relative;" src="static/data_plot.html" frameborder="0" allowfullscreen>-->
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
||||
<h3>Current Runs</h3>
|
||||
{{ current_data | safe }}
|
||||
|
||||
<h3>Previous Runs</h3>
|
||||
{{ beer_data | safe }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user