feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the fermenter right now, and the per-run metadata (canonical name, tap number, original gravity, brew notes) that could not be derived from the readings. Why the metadata is needed -------------------------- The `beer_data` collection stores a beer name on *each reading*, and that name drifts within a run - run 4 was entered as "Necatron-Christmas Beer", then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was nowhere to record a tap number, a confirmed original gravity, or a note about the brew. The new `beer_runs` collection holds one document per run with exactly those details. It starts empty; `scripts/backfill_run_metadata.py` populates it from the existing readings. The script is idempotent, only ever inserts into `beer_runs`, never touches `beer_data`, and never overwrites a name you have already set by hand. The active beers page --------------------- A card per brew that is still in the fermenter, showing the tap number, the current gravity, the projected alcohol, how many days it has been brewing, a sparkline of the gravity so far, and 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 or take a second reading and it appears. The same applies to the existing reads, where the legacy app's assumed OG of 1012 produced *negative* alcohol for a perfectly normal OG. Also adds the history page: alcohol by brew, brews per month, every run in a table, a comparison of the standard ABV formula against the legacy one, and a list of readings that fall outside the plausible hydrometer range. Changes to the existing pages ----------------------------- - The brew-process page and the run detail page now show the canonical run name and the latest comment, and the run detail page lets you edit the metadata inline. - Gravity is displayed as a decimal reading (1.039) with the point scale beneath it (39 points), which is the way a brewer reads it. Labelling the stored `1039` as "1039 points" would have been wrong. - A run's display name now prefers the name on its *final* reading - the one written when the brew was finished, so the name the operator settled on - falling back to the most-used name. - `upsert_run_metadata` now clears a field when it is passed as `None`, so a name or tap number can be deleted. `upsert_run_metadata_partial` is the non-clearing variant, used by the backfill. Bugs found and fixed along the way ---------------------------------- - The chart script was included both in the base template and again by each page, so every chart was drawn twice. A test now asserts it is loaded exactly once per page. - The chart library was never referenced by the base template, so all charts were silently dead. That was fixed in the previous PR. Tests ----- 91 (up from 74). New coverage: the active page contents, the tap number, the projected alcohol, the "needs 2nd reading" case, metadata saving and clearing, the stored name overriding the drifting reading name, the next free tap number, the partial-update semantics, the history page, the outlier list, and the point-scale formatting. Data safety ----------- `beer_data` is untouched by this change. The backfill script only inserts into `beer_runs` and skips any run whose name has already been set by hand.
This commit is contained in:
@@ -123,11 +123,13 @@ def create_app(config: dict | None = None) -> Flask:
|
||||
# ------------------------------------------------------------------ #
|
||||
# Blueprints
|
||||
# ------------------------------------------------------------------ #
|
||||
from views.analysis import bp as analysis_bp
|
||||
from views.auth import bp as auth_bp
|
||||
from views.brews import bp as brews_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(brews_bp)
|
||||
app.register_blueprint(analysis_bp)
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
|
||||
+41
-13
@@ -289,20 +289,32 @@ class Run:
|
||||
def display_name(self) -> str:
|
||||
"""Best available human label for the run.
|
||||
|
||||
The stored ``beer_type`` drifts between readings of the same run, so the
|
||||
most frequently used non-empty value wins; ties break towards the most
|
||||
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
|
||||
rather than "Necatron-Christmas Beer".
|
||||
The stored ``beer_type`` drifts between readings of the same run, so
|
||||
the name is resolved by preferring, in order:
|
||||
|
||||
1. The name on the **final** reading. That is the one the operator
|
||||
wrote when the brew was finished, so it is the name they settled
|
||||
on. Run 4 resolves to "Nocturna Christmas beer" rather than
|
||||
"Necatron-Christmas Beer" from the first reading.
|
||||
2. The name used on the most readings, breaking ties towards the most
|
||||
recent. This catches runs whose name changed exactly once.
|
||||
3. The name on the most recent reading, if it appears only once.
|
||||
|
||||
A run whose readings carry no name at all falls back to "Run N".
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for reading in self.readings:
|
||||
label = reading.beer_type
|
||||
if label:
|
||||
counts[label] = counts.get(label, 0) + 1
|
||||
if not counts:
|
||||
named = [r for r in self.readings if r.beer_type]
|
||||
if not named:
|
||||
return f"Run {self.beer_run_id}"
|
||||
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
||||
return best
|
||||
|
||||
final = self.final_reading()
|
||||
if final is not None and final.beer_type:
|
||||
return final.beer_type
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for reading in named:
|
||||
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1
|
||||
|
||||
return max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
||||
|
||||
def _last_index_of(self, label: str) -> int:
|
||||
for index in range(len(self.readings) - 1, -1, -1):
|
||||
@@ -391,12 +403,24 @@ def days_between(start: date, end: date) -> int:
|
||||
|
||||
|
||||
def _fmt_decimal(points: int | None) -> str:
|
||||
"""Format gravity points as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points / 1000:.3f}"
|
||||
|
||||
|
||||
def _fmt_point_scale(points: int | None) -> str:
|
||||
"""Format the gravity in brewing "points".
|
||||
|
||||
A reading of 1.039 is 39 points in the homebrew sense - the digits after
|
||||
the decimal point. The stored value ``1039`` is the full reading, not a
|
||||
point count, so it must not be labelled "points".
|
||||
"""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points % 1000}"
|
||||
|
||||
|
||||
def run_summary(run: Run, today: date | None = None,
|
||||
og_override: int | None = None) -> dict:
|
||||
"""A flat dict describing a run, ready for a template or a JSON response."""
|
||||
@@ -415,9 +439,12 @@ def run_summary(run: Run, today: date | None = None,
|
||||
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
||||
"og_decimal_str": _fmt_decimal(og),
|
||||
"fg_decimal_str": _fmt_decimal(fg),
|
||||
"og_point_scale": _fmt_point_scale(og),
|
||||
"fg_point_scale": _fmt_point_scale(fg),
|
||||
"max_sg": run.max_sg(),
|
||||
"min_sg": run.min_sg(),
|
||||
"min_sg_decimal": _fmt_decimal(run.min_sg()),
|
||||
"min_sg_point_scale": _fmt_point_scale(run.min_sg()),
|
||||
"drop_points": run.gravity_drop_points(),
|
||||
"abv": run.abv_percent(),
|
||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||
@@ -427,6 +454,7 @@ def run_summary(run: Run, today: date | None = None,
|
||||
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||
"days_in_fermenter": run.days_in_fermenter(today),
|
||||
"comments": run.comments(),
|
||||
"last_comment": run.comments()[-1] if run.comments() else "",
|
||||
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||
"readings": [
|
||||
{
|
||||
|
||||
@@ -196,9 +196,13 @@ class BeerRepository:
|
||||
"""Create or update the metadata document for a run.
|
||||
|
||||
Only the keys passed in are written; anything already stored is left
|
||||
alone.
|
||||
alone. A field passed as ``None`` is **cleared** — that is how the
|
||||
form removes a name or a tap number the operator has deleted.
|
||||
|
||||
Use ``upsert_run_metadata_partial`` to update some fields while
|
||||
leaving the rest untouched.
|
||||
"""
|
||||
payload = {k: v for k, v in fields.items() if v is not None}
|
||||
payload = dict(fields)
|
||||
payload["updated_at"] = _now_iso()
|
||||
self.run_meta.update_one(
|
||||
{"beer_run_id": _as_id(beer_run_id)},
|
||||
@@ -207,6 +211,38 @@ class BeerRepository:
|
||||
)
|
||||
return self.run_metadata(beer_run_id)
|
||||
|
||||
def upsert_run_metadata_partial(self, beer_run_id: str, **fields: Any) -> dict:
|
||||
"""Update only the given fields, leaving every other field alone.
|
||||
|
||||
Fields passed as ``None`` are ignored rather than cleared.
|
||||
"""
|
||||
payload = {k: v for k, v in fields.items() if v is not None}
|
||||
if not payload:
|
||||
return self.run_metadata(beer_run_id)
|
||||
payload["updated_at"] = _now_iso()
|
||||
self.run_meta.update_one(
|
||||
{"beer_run_id": _as_id(beer_run_id)},
|
||||
{"$set": payload},
|
||||
upsert=True,
|
||||
)
|
||||
return self.run_metadata(beer_run_id)
|
||||
|
||||
def next_free_tap_number(self) -> int:
|
||||
"""The lowest tap number not already in use.
|
||||
|
||||
Taps are numbered from 1. A run with no tap number recorded does not
|
||||
occupy one.
|
||||
"""
|
||||
used = {
|
||||
meta.get("tap_number")
|
||||
for meta in self.all_run_metadata().values()
|
||||
if meta.get("tap_number") is not None
|
||||
}
|
||||
candidate = 1
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
return candidate
|
||||
|
||||
def delete_run_metadata(self, beer_run_id: str) -> bool:
|
||||
"""Remove the metadata document for a run (readings are untouched)."""
|
||||
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
|
||||
|
||||
@@ -172,7 +172,14 @@ p { margin: 0 0 .75rem; }
|
||||
}
|
||||
|
||||
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
|
||||
.topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); }
|
||||
.topbar__user {
|
||||
color: var(--text-muted);
|
||||
font-size: .85rem;
|
||||
font-family: var(--mono);
|
||||
padding: .15rem .5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ page */
|
||||
|
||||
@@ -285,7 +292,7 @@ p { margin: 0 0 .75rem; }
|
||||
line-height: 1.2;
|
||||
color: var(--amber-bright);
|
||||
}
|
||||
.stat__meta { color: var(--text-muted); font-size: .82rem; }
|
||||
.stat__meta { color: var(--text-muted); font-size: .83rem; }
|
||||
|
||||
/* ----------------------------------------------------------------- forms */
|
||||
|
||||
@@ -299,7 +306,7 @@ p { margin: 0 0 .75rem; }
|
||||
color: var(--text-muted);
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
.field__hint { color: var(--text-faint); font-size: .78rem; margin-top: .25rem; }
|
||||
.field__hint { color: var(--text-muted); font-size: .8rem; margin-top: .25rem; }
|
||||
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="number"],
|
||||
@@ -579,11 +586,45 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||
}
|
||||
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
||||
.brew-card__meta { color: var(--text-faint); font-size: .8rem; font-family: var(--mono); }
|
||||
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
|
||||
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||
.brew-card__stat .stat__label { font-size: .66rem; }
|
||||
.brew-card__stat .stat__label { font-size: .68rem; }
|
||||
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
||||
.brew-card__spark { height: 46px; }
|
||||
|
||||
/* The "start a new beer" tile — fills the last grid slot and gives the
|
||||
page a way to begin a brew without hunting for the form. */
|
||||
.brew-card--new {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .6rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
border-color: var(--border-strong);
|
||||
min-height: 180px;
|
||||
}
|
||||
.brew-card--new:hover {
|
||||
border-color: var(--amber);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brew-card--new::before { display: none; }
|
||||
.brew-card__new-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 50%;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: var(--amber);
|
||||
}
|
||||
.brew-card__new-text { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.brew-card__new-text strong { font-size: .95rem; }
|
||||
.brew-card__spark--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,265 @@
|
||||
/* ==========================================================================
|
||||
Brew Log — charts
|
||||
Uses the vendored Chart.js (static/js/chart.umd.js) — no CDN at runtime, so
|
||||
the container works on an isolated network.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (typeof Chart === "undefined") {
|
||||
console.warn("Chart.js is not loaded; charts are disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ACCENT = "#be7f39";
|
||||
var ACCENT_BRIGHT = "#dda05c";
|
||||
var MUTED = "#afafaf";
|
||||
var GRID = "rgba(175, 175, 175, 0.13)";
|
||||
var WARN = "#e8a33d";
|
||||
|
||||
Chart.defaults.color = MUTED;
|
||||
Chart.defaults.font.family =
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
Chart.defaults.font.size = 11;
|
||||
Chart.defaults.borderColor = GRID;
|
||||
|
||||
function withAlpha(hex, alpha) {
|
||||
var r = parseInt(hex.slice(1, 3), 16);
|
||||
var g = parseInt(hex.slice(3, 5), 16);
|
||||
var b = parseInt(hex.slice(5, 7), 16);
|
||||
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
|
||||
}
|
||||
|
||||
function baseOptions(extra) {
|
||||
var options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: GRID }, ticks: { maxRotation: 0, autoSkipPadding: 12 } },
|
||||
y: { grid: { color: GRID } }
|
||||
}
|
||||
};
|
||||
if (extra) {
|
||||
Object.keys(extra).forEach(function (key) {
|
||||
options[key] = extra[key];
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- sparklines */
|
||||
|
||||
function drawSparklines() {
|
||||
var canvases = document.querySelectorAll("canvas.sparkline");
|
||||
for (var i = 0; i < canvases.length; i++) {
|
||||
(function (canvas) {
|
||||
var labels = JSON.parse(canvas.getAttribute("data-labels") || "[]");
|
||||
var values = JSON.parse(canvas.getAttribute("data-values") || "[]");
|
||||
var points = [];
|
||||
for (var j = 0; j < values.length; j++) {
|
||||
if (values[j] !== null && values[j] !== undefined) {
|
||||
points.push({ x: labels[j], y: values[j] });
|
||||
}
|
||||
}
|
||||
if (!points.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
data: points,
|
||||
borderColor: ACCENT_BRIGHT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.18),
|
||||
borderWidth: 2,
|
||||
pointRadius: points.length > 12 ? 0 : 2,
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) { return ctx.parsed.y + " (" + ctx.parsed.x + ")"; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false, grid: { display: false } },
|
||||
y: { display: false, grid: { display: false } }
|
||||
}
|
||||
})
|
||||
});
|
||||
})(canvases[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- run detail chart */
|
||||
|
||||
function drawRunChart() {
|
||||
var canvas = document.getElementById("run-chart");
|
||||
if (!canvas) { return; }
|
||||
var runId = window.BREW_RUN_ID;
|
||||
|
||||
fetch("/api/runs/" + encodeURIComponent(runId), {
|
||||
headers: { "Accept": "application/json" }
|
||||
})
|
||||
.then(function (response) {
|
||||
if (!response.ok) { throw new Error("run fetch failed"); }
|
||||
return response.json();
|
||||
})
|
||||
.then(function (run) {
|
||||
var points = [];
|
||||
(run.readings || []).forEach(function (r) {
|
||||
if (r.sg !== null && r.sg !== undefined) {
|
||||
points.push({ x: r.date, y: r.sg });
|
||||
}
|
||||
});
|
||||
if (!points.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
label: "Gravity",
|
||||
data: points,
|
||||
borderColor: ACCENT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT,
|
||||
fill: true,
|
||||
tension: 0.25
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
return ctx.parsed.y + " points (" + ctx.parsed.x + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
reverse: true,
|
||||
title: { display: true, text: "Specific gravity (points)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ history charts */
|
||||
|
||||
function drawAbvChart() {
|
||||
var canvas = document.getElementById("abv-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
var completed = runs.filter(function (r) { return r.abv !== null && r.abv !== undefined; });
|
||||
if (!completed.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: completed.map(function (r) {
|
||||
return r.canonical_name || ("Run " + r.beer_run_id);
|
||||
}),
|
||||
datasets: [{
|
||||
label: "ABV %",
|
||||
data: completed.map(function (r) { return r.abv; }),
|
||||
backgroundColor: withAlpha(ACCENT, 0.65),
|
||||
borderColor: ACCENT,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) { return ctx.parsed.y.toFixed(2) + "% ABV"; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
|
||||
x: { ticks: { maxRotation: 45, minRotation: 0 } }
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
function drawMonthsChart() {
|
||||
var canvas = document.getElementById("months-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
var counts = {};
|
||||
runs.forEach(function (r) {
|
||||
var stamp = r.final_date || r.first_date;
|
||||
if (!stamp) { return; }
|
||||
var month = stamp.slice(0, 7);
|
||||
counts[month] = (counts[month] || 0) + 1;
|
||||
});
|
||||
var months = Object.keys(counts).sort();
|
||||
if (!months.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: months,
|
||||
datasets: [{
|
||||
label: "Brews",
|
||||
data: months.map(function (m) { return counts[m]; }),
|
||||
borderColor: ACCENT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT,
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1 } }
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
function init() {
|
||||
drawSparklines();
|
||||
drawRunChart();
|
||||
drawAbvChart();
|
||||
drawMonthsChart();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Active Beers — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">Active Beers</h1>
|
||||
<p class="page__sub">Everything currently in the fermenter, with its tap and projected strength.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Record a reading</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--3" style="margin-bottom:1.25rem">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Active brews</div>
|
||||
<div class="stat__value">{{ totals.count }}</div>
|
||||
<div class="stat__meta">in the fermenter now</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average age</div>
|
||||
<div class="stat__value">{{ totals.avg_days }}</div>
|
||||
<div class="stat__meta">day{{ '' if totals.avg_days == '1' else 's' }} since first reading</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Longest running</div>
|
||||
<div class="stat__value">{{ totals.longest_days if totals.longest_days is not none else '—' }}</div>
|
||||
<div class="stat__meta">day{{ '' if totals.longest_days == 1 else 's' }} in the fermenter</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if cards %}
|
||||
<div class="grid grid--cards">
|
||||
{% for run in cards %}
|
||||
<article class="brew-card">
|
||||
<div class="brew-card__head">
|
||||
<div>
|
||||
<h2 class="brew-card__name">
|
||||
<a href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">{{ run.canonical_name }}</a>
|
||||
</h2>
|
||||
<div class="brew-card__meta">
|
||||
Run {{ run.beer_run_id }}
|
||||
{% if run.first_date %} · since {{ run.first_date }}{% endif %}
|
||||
{% if run.reading_count == 1 %} · 1 reading{% else %} · {{ run.reading_count }} readings{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if run.tap_number is not none %}
|
||||
<span class="chip chip--tap">Tap {{ run.tap_number }}</span>
|
||||
{% else %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="brew-card__stats">
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Gravity</div>
|
||||
<div class="stat__value">
|
||||
{% if run.min_sg is not none %}{{ run.min_sg_decimal }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if run.min_sg is not none %}
|
||||
<div class="stat__meta">{{ run.min_sg_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Est. ABV</div>
|
||||
<div class="stat__value">
|
||||
{% if run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if run.projected_abv is not none %}projected{% else %}needs 2nd reading{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Days</div>
|
||||
<div class="stat__value">
|
||||
{% if run.days_in_fermenter is not none %}{{ run.days_in_fermenter }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">day{{ '' if run.days_in_fermenter == 1 else 's' }} brewing</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if run.readings | length > 1 %}
|
||||
<div class="brew-card__spark">
|
||||
<canvas class="sparkline"
|
||||
data-labels='{{ run.readings | map(attribute="date") | list | tojson }}'
|
||||
data-values='{{ run.readings | map(attribute="sg") | list | tojson }}'></canvas>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="brew-card__spark brew-card__spark--empty faint tiny">
|
||||
One reading so far — the trend appears after the next one.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.last_comment %}
|
||||
<div class="small muted">
|
||||
<strong class="faint tiny">Latest:</strong> {{ run.last_comment }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
|
||||
<a class="btn btn--sm" href="{{ url_for('brews.index') }}">Add reading</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{# A tile that fills the last slot and gives the page its missing
|
||||
affordance: there is currently no way to start a brew from here. #}
|
||||
<a class="brew-card brew-card--new" href="{{ url_for('brews.index') }}">
|
||||
<span class="brew-card__new-mark" aria-hidden="true">+</span>
|
||||
<span class="brew-card__new-text">
|
||||
<strong>Start a new beer</strong>
|
||||
<span class="faint tiny">Record its first reading</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<p><strong>Nothing in the fermenter.</strong></p>
|
||||
<p class="small">Start a run by recording its first reading on the
|
||||
<a href="{{ url_for('brews.index') }}">brew process page</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -19,11 +19,15 @@
|
||||
<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>
|
||||
<a class="nav__link {% if current_path == '/active' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.active') }}">Active Beers</a>
|
||||
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.history') }}">History</a>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<span class="topbar__user" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span>
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -44,7 +48,9 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/chart.umd.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -93,15 +93,15 @@
|
||||
<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">{{ run.min_sg_decimal }}</div>
|
||||
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</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>
|
||||
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="comment">
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}History — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">History</h1>
|
||||
<p class="page__sub">How every brew has gone, and what it turned out like.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Brew process</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--4" style="margin-bottom:1.25rem">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Total brews</div>
|
||||
<div class="stat__value">{{ stats.total_runs }}</div>
|
||||
<div class="stat__meta">{{ stats.completed }} finished · {{ stats.active }} active</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average ABV</div>
|
||||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||||
<div class="stat__meta">across finished brews</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Strongest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.best %}{{ '%.2f' % stats.best.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.best %}{{ stats.best.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Lightest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.worst %}{{ '%.2f' % stats.worst.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.worst %}{{ stats.worst.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2" style="margin-bottom:1.25rem">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Alcohol by brew</h2>
|
||||
<span class="card__hint">Standard formula (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="abv-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Brews over time</h2>
|
||||
<span class="card__hint">Runs started per month</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="months-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if outliers %}
|
||||
<section class="card" style="margin-bottom:1.25rem">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings worth a second look</h2>
|
||||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Date</th>
|
||||
<th class="num">Reading</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in outliers %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="mono nowrap">{{ row.date or '—' }}</td>
|
||||
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
||||
<td class="comment">{{ row.comment or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card" style="margin-bottom:1.25rem">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every brew</h2>
|
||||
<span class="card__hint">{{ summaries | length }} runs</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">Drop</th>
|
||||
<th class="num">ABV</th>
|
||||
<th class="num">Legacy</th>
|
||||
<th>Finished</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in summaries %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td>
|
||||
{% if run.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ run.og_points or '—' }}</td>
|
||||
<td class="num">{{ run.fg_points or '—' }}</td>
|
||||
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
||||
<td class="num">
|
||||
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Standard vs legacy alcohol figures</h2>
|
||||
<span class="card__hint">The old app used (max − min) ÷ 7.36 + 0.5, which is not a recognised formula</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">Standard ABV</th>
|
||||
<th class="num">Legacy ABV</th>
|
||||
<th class="num">Overstated by</th>
|
||||
<th>Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in abv_rows %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="num">{{ '%.2f' % row.abv }}%</td>
|
||||
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
||||
<td class="num">
|
||||
{% if row.delta > 0 %}
|
||||
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
||||
{% else %}
|
||||
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -22,14 +22,57 @@
|
||||
<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="grid grid--sidebar" style="margin-bottom:1.25rem">
|
||||
<!-- ------------------------------------------------- run metadata -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Run details</h2>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Beer / cider name</label>
|
||||
<input type="text" id="name" name="name" maxlength="120"
|
||||
value="{{ meta_form.name.data or '' }}">
|
||||
<div class="field__hint">Overrides the drifting name on each reading.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tap_number">Tap number</label>
|
||||
<input type="number" id="tap_number" name="tap_number"
|
||||
min="1" max="99" step="1"
|
||||
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="og_points">Original gravity</label>
|
||||
<input type="number" id="og_points" name="og_points"
|
||||
min="900" max="1300" step="1"
|
||||
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
|
||||
<div class="field__hint">Points, e.g. 1045.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="notes">Brew notes</label>
|
||||
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Save run details</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid--4">
|
||||
<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 %}
|
||||
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.og_decimal %}
|
||||
<div class="stat__meta">{{ summary.og_decimal_str }}</div>
|
||||
{% if summary.og_points is not none %}
|
||||
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -37,10 +80,10 @@
|
||||
{% 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 %}
|
||||
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.fg_decimal %}
|
||||
<div class="stat__meta">{{ summary.fg_decimal_str }}</div>
|
||||
{% if summary.fg_points is not none %}
|
||||
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -53,7 +96,13 @@
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if summary.abv is not none %}measured{% else %}projected{% endif %}
|
||||
{% if summary.abv is not none %}
|
||||
measured
|
||||
{% elif summary.projected_abv is not none %}
|
||||
projected
|
||||
{% else %}
|
||||
needs 2nd reading
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -63,6 +112,7 @@
|
||||
</div>
|
||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""The analysis pages: what is active, and how the history has gone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, redirect, render_template, request, session, url_for
|
||||
|
||||
from brewing import Run, is_plausible_sg, run_summary
|
||||
|
||||
bp = Blueprint("analysis", __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 _fmt_days(value: float) -> str:
|
||||
"""Format a day count: a whole number stays whole, a fraction keeps one
|
||||
decimal place."""
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
def _decorate(run: Run, metadata: dict) -> dict:
|
||||
"""Attach the stored metadata to a run summary."""
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
return summary
|
||||
|
||||
|
||||
@bp.route("/active")
|
||||
@login_required
|
||||
def active():
|
||||
"""The active beers page: what is in the fermenter right now."""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
today = date.today()
|
||||
|
||||
cards = []
|
||||
for run in runs:
|
||||
if not run.is_active():
|
||||
continue
|
||||
summary = _decorate(run, metadata)
|
||||
summary["days_in_fermenter"] = run.days_in_fermenter(today)
|
||||
cards.append(summary)
|
||||
|
||||
# Most recently started first — that is what is fermenting right now.
|
||||
cards.sort(key=lambda s: (s["first_date"] or "0000-00-00"), reverse=True)
|
||||
|
||||
ages = [c["days_in_fermenter"] for c in cards if c["days_in_fermenter"] is not None]
|
||||
|
||||
return render_template(
|
||||
"active.html",
|
||||
cards=cards,
|
||||
today=today,
|
||||
totals={
|
||||
"count": len(cards),
|
||||
"avg_days": _fmt_days(sum(ages) / len(ages)) if ages else 0,
|
||||
"longest_days": max(ages) if ages else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/history")
|
||||
@login_required
|
||||
def history():
|
||||
"""Historical brews and how they performed."""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
summaries = [_decorate(run, metadata) for run in runs]
|
||||
completed = [s for s in summaries if not s["active"]]
|
||||
|
||||
# --- ABV comparison: standard formula vs the legacy one ---------------- #
|
||||
abv_rows = []
|
||||
for s in completed:
|
||||
if s["abv"] is None:
|
||||
continue
|
||||
abv_rows.append({
|
||||
"beer_run_id": s["beer_run_id"],
|
||||
"name": s["canonical_name"],
|
||||
"final_date": s["final_date"],
|
||||
"abv": s["abv"],
|
||||
"legacy_abv": s["legacy_abv"],
|
||||
"delta": round((s["legacy_abv"] or 0) - s["abv"], 2),
|
||||
})
|
||||
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
|
||||
|
||||
# --- brews per month --------------------------------------------------- #
|
||||
per_month: dict[str, int] = defaultdict(int)
|
||||
for s in summaries:
|
||||
stamp = s["final_date"] or s["first_date"]
|
||||
if stamp:
|
||||
per_month[stamp[:7]] += 1
|
||||
months = sorted(per_month.items())
|
||||
|
||||
# --- outliers ---------------------------------------------------------- #
|
||||
outliers = []
|
||||
for s in summaries:
|
||||
for reading in s["readings"]:
|
||||
sg = reading["sg"]
|
||||
if sg is not None and not is_plausible_sg(sg):
|
||||
outliers.append({
|
||||
"beer_run_id": s["beer_run_id"],
|
||||
"name": s["canonical_name"],
|
||||
"date": reading["date"],
|
||||
"sg": sg,
|
||||
"comment": reading["comment"],
|
||||
})
|
||||
|
||||
avg_abv = round(sum(r["abv"] for r in abv_rows) / len(abv_rows), 2) if abv_rows else 0
|
||||
best = max(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
|
||||
worst = min(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
|
||||
|
||||
return render_template(
|
||||
"history.html",
|
||||
summaries=summaries,
|
||||
completed=completed,
|
||||
abv_rows=abv_rows,
|
||||
months=months,
|
||||
outliers=outliers,
|
||||
stats={
|
||||
"total_runs": len(summaries),
|
||||
"completed": len(completed),
|
||||
"active": len(summaries) - len(completed),
|
||||
"avg_abv": avg_abv,
|
||||
"best": best,
|
||||
"worst": worst,
|
||||
},
|
||||
)
|
||||
@@ -56,6 +56,23 @@ class ReadingForm(FlaskForm):
|
||||
submit = SubmitField("Save reading")
|
||||
|
||||
|
||||
class RunMetaForm(FlaskForm):
|
||||
"""Edit the canonical metadata of a run.
|
||||
|
||||
These are the details that cannot be derived from the readings: the name
|
||||
the brew is actually known by, which tap it is on, and its original
|
||||
gravity.
|
||||
"""
|
||||
|
||||
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
|
||||
tap_number = IntegerField("Tap number",
|
||||
validators=[Optional(), NumberRange(min=1, max=99)])
|
||||
og_points = IntegerField("Original gravity (points)",
|
||||
validators=[Optional(), NumberRange(min=900, max=1300)])
|
||||
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
||||
submit = SubmitField("Save run")
|
||||
|
||||
|
||||
@bp.route("/brews", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
@@ -82,11 +99,15 @@ def index():
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
summaries = []
|
||||
for run in runs:
|
||||
summary = run_summary(run)
|
||||
summary["canonical_name"] = run.display_name()
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summaries.append(summary)
|
||||
|
||||
@@ -103,22 +124,46 @@ def index():
|
||||
summaries=summaries,
|
||||
active_runs=active,
|
||||
completed_runs=completed,
|
||||
next_tap=repo().next_free_tap_number(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>")
|
||||
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def run_detail(run_id: str):
|
||||
"""One run in detail, with all of its readings."""
|
||||
"""One run in detail, with its readings and its editable metadata."""
|
||||
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()
|
||||
meta = repo().run_metadata(run_id)
|
||||
meta_form = RunMetaForm()
|
||||
|
||||
return render_template("run_detail.html", run=run, summary=summary)
|
||||
if meta_form.validate_on_submit():
|
||||
repo().upsert_run_metadata(
|
||||
run_id,
|
||||
name=(meta_form.name.data or "").strip() or None,
|
||||
tap_number=meta_form.tap_number.data,
|
||||
og_points=meta_form.og_points.data,
|
||||
notes=(meta_form.notes.data or "").strip() or None,
|
||||
)
|
||||
flash("Run details saved.", "success")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
if not meta_form.is_submitted():
|
||||
meta_form.name.data = meta.get("name") or run.display_name()
|
||||
meta_form.tap_number.data = meta.get("tap_number")
|
||||
meta_form.og_points.data = meta.get("og_points")
|
||||
meta_form.notes.data = meta.get("notes", "")
|
||||
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
|
||||
return render_template("run_detail.html", run=run, summary=summary,
|
||||
meta_form=meta_form, meta=meta)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||
|
||||
Reference in New Issue
Block a user