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:
@@ -0,0 +1,152 @@
|
||||
"""The brew-process pages: record readings and review the runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import (Blueprint, flash, redirect, render_template, request,
|
||||
session, url_for)
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||
SubmitField, TextAreaField)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
from brewing import is_plausible_sg, run_summary
|
||||
|
||||
bp = Blueprint("brews", __name__)
|
||||
|
||||
|
||||
def repo():
|
||||
"""The request-scoped repository (one Mongo client per request)."""
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["beer_repo"]()
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""Redirect to the login page when there is no session."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _last_reading_date(run) -> str | None:
|
||||
"""The ISO date of the most recent reading in a run."""
|
||||
last = run.last_reading()
|
||||
if last is None or last.date is None:
|
||||
return None
|
||||
return last.date.isoformat()
|
||||
|
||||
|
||||
class ReadingForm(FlaskForm):
|
||||
"""Add or update a gravity reading."""
|
||||
|
||||
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
|
||||
beer_run_type = StringField("Beer / cider name",
|
||||
validators=[Optional(), Length(max=120)])
|
||||
Date = DateField("Reading date", validators=[DataRequired()])
|
||||
sg = IntegerField("Gravity reading (points)",
|
||||
validators=[DataRequired(), NumberRange(min=900, max=1300)])
|
||||
final_reading = BooleanField("This is the final reading")
|
||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||
submit = SubmitField("Save reading")
|
||||
|
||||
|
||||
@bp.route("/brews", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
"""The brew-process page: record a reading, then review the runs."""
|
||||
form = ReadingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
sg_points = int(form.sg.data)
|
||||
if not is_plausible_sg(sg_points):
|
||||
flash(
|
||||
f"{sg_points} is outside the usual hydrometer range "
|
||||
"(990-1200). It was saved anyway — double-check the reading.",
|
||||
"warning",
|
||||
)
|
||||
repo().upsert_reading(
|
||||
beer_run_id=str(form.beer_run_id.data),
|
||||
beer_type=(form.beer_run_type.data or "").strip(),
|
||||
sg=sg_points,
|
||||
reading_date=form.Date.data.isoformat(),
|
||||
final_reading=bool(form.final_reading.data),
|
||||
comment=(form.comment.data or "").strip(),
|
||||
)
|
||||
flash("Reading saved.", "success")
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
runs = repo().runs()
|
||||
|
||||
summaries = []
|
||||
for run in runs:
|
||||
summary = run_summary(run)
|
||||
summary["canonical_name"] = run.display_name()
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summaries.append(summary)
|
||||
|
||||
# Active runs: most recent activity first — that is what is fermenting now.
|
||||
active = sorted([s for s in summaries if s["active"]],
|
||||
key=lambda s: s["last_date"] or "", reverse=True)
|
||||
# Finished runs: most recently finished first.
|
||||
completed = sorted([s for s in summaries if not s["active"]],
|
||||
key=lambda s: s["final_date"] or "", reverse=True)
|
||||
|
||||
return render_template(
|
||||
"brews.html",
|
||||
form=form,
|
||||
summaries=summaries,
|
||||
active_runs=active,
|
||||
completed_runs=completed,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>")
|
||||
@login_required
|
||||
def run_detail(run_id: str):
|
||||
"""One run in detail, with all of its readings."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
flash(f"No run numbered {run_id}.", "error")
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
summary = run_summary(run)
|
||||
summary["canonical_name"] = run.display_name()
|
||||
|
||||
return render_template("run_detail.html", run=run, summary=summary)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||
@login_required
|
||||
def mark_final(run_id: str):
|
||||
"""Flag a reading as the final one for its run."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
changed = repo().set_final_reading(run_id, reading_date, True)
|
||||
if changed:
|
||||
flash("Final reading recorded.", "success")
|
||||
else:
|
||||
flash("That reading could not be updated.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
|
||||
@login_required
|
||||
def unfinal(run_id: str):
|
||||
"""Un-flag a final reading, putting the run back into the active list."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
repo().set_final_reading(run_id, reading_date, False)
|
||||
flash("Reading is no longer marked final.", "info")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
Reference in New Issue
Block a user