Compare commits
1
Commits
master
...
6918b28621
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6918b28621 |
@@ -34,6 +34,56 @@ python -m pytest tests/ -q
|
||||
|
||||
The tests use `mongomock`, so they need no database and no network.
|
||||
|
||||
## The pages
|
||||
|
||||
| Page | What it is for |
|
||||
|---|---|
|
||||
| `/login` | Sign in |
|
||||
| `/brews` | The brew process: record a reading, review every run |
|
||||
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
|
||||
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
|
||||
|
||||
### `/active` — the active beers page
|
||||
|
||||
A card per brew that is still in the fermenter (a run with no reading marked
|
||||
final), showing:
|
||||
|
||||
- **the tap number** it is on,
|
||||
- **the current gravity**, as a decimal reading with the point scale beneath,
|
||||
- **the projected alcohol**, worked out from the run's original gravity,
|
||||
- **how many days** it has been brewing,
|
||||
- a **sparkline** of the gravity so far,
|
||||
- **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 on the run (or take a second reading) and it appears.
|
||||
|
||||
### Run metadata
|
||||
|
||||
Some details cannot be derived from the readings, so they are stored per run in
|
||||
the `beer_runs` collection and edited on the run detail page:
|
||||
|
||||
- the **canonical name** the brew is known by (overriding the drifting
|
||||
per-reading name),
|
||||
- the **tap number**,
|
||||
- the **original gravity**,
|
||||
- free-text **brew notes**.
|
||||
|
||||
### Populating the run metadata from the existing readings
|
||||
|
||||
```bash
|
||||
# dry run - prints what it would do, writes nothing
|
||||
python scripts/backfill_run_metadata.py
|
||||
|
||||
# write it
|
||||
python scripts/backfill_run_metadata.py --apply
|
||||
```
|
||||
|
||||
The script is idempotent and non-destructive: it only inserts into
|
||||
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
|
||||
already set by hand (pass `--force` if you want it to).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -41,11 +91,13 @@ src/flask/
|
||||
beer_data.py entry point; exposes `app` and `create_app`
|
||||
app_factory.py application factory, request-scoped Mongo repository
|
||||
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
||||
views/ blueprints: auth, brews, (later) analysis
|
||||
views/ blueprints: auth, brews, analysis
|
||||
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
||||
templates/ Jinja templates
|
||||
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
||||
static/ CSS, JS, fonts, logo (all served locally - no CDN)
|
||||
tests/ pytest suite
|
||||
scripts/ operational scripts (the run-metadata backfill)
|
||||
```
|
||||
|
||||
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
||||
|
||||
+24
@@ -130,6 +130,30 @@ livenessProbe:
|
||||
periodSeconds: 20
|
||||
```
|
||||
|
||||
## The run metadata collection
|
||||
|
||||
The `beer_runs` collection holds one document per brew run with the details
|
||||
that cannot be derived from the readings: the canonical name, the tap number,
|
||||
the recorded original gravity and the brew notes.
|
||||
|
||||
It starts empty on a fresh install. Populate it from the existing readings:
|
||||
|
||||
```bash
|
||||
# from the app's source tree, with the usual env vars set
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
```
|
||||
|
||||
The script is idempotent and only ever inserts into `beer_runs`. It does not
|
||||
touch `beer_data`.
|
||||
|
||||
To see what is in it:
|
||||
|
||||
```bash
|
||||
mongosh "mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
|
||||
--eval 'db.beer_runs.find().pretty()'
|
||||
```
|
||||
|
||||
## Data safety
|
||||
|
||||
The `beer_db.beer_data` collection holds the actual brewing records. There is
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill the ``beer_runs`` metadata collection from the existing readings.
|
||||
|
||||
The ``beer_data`` collection stores a beer name on *each reading*, and that
|
||||
name drifts across a run — run 4 was entered as "Necatron-Christmas Beer",
|
||||
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". The
|
||||
``beer_runs`` collection holds one canonical name per run, which is what the
|
||||
active-beers page and the analysis pages use.
|
||||
|
||||
This script populates that collection from the readings. It is **idempotent**
|
||||
and **non-destructive**:
|
||||
|
||||
- It only ever inserts into ``beer_runs``. It never touches ``beer_data``.
|
||||
- It never overwrites a name you have already set by hand. A run whose
|
||||
metadata already carries a ``name`` is left alone unless ``--force`` is
|
||||
given.
|
||||
- Re-running it produces the same result.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
python scripts/backfill_run_metadata.py --apply --force # overwrite names
|
||||
|
||||
The connection is taken from the same environment variables the app uses:
|
||||
``MONGO_HOST``, ``MONGO_USER``, ``MONGO_PASS`` (or ``MONGO_URI``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask"),
|
||||
)
|
||||
|
||||
from brewing import Run # noqa: E402
|
||||
from mongo.repository import BeerRepository # noqa: E402
|
||||
|
||||
|
||||
def build_plan(repo: BeerRepository) -> list[dict]:
|
||||
"""Work out what should be in ``beer_runs``, one entry per run."""
|
||||
existing = repo.all_run_metadata()
|
||||
plan = []
|
||||
|
||||
for run in repo.runs():
|
||||
metadata = existing.get(run.beer_run_id, {})
|
||||
canonical = metadata.get("name") or run.display_name()
|
||||
og = metadata.get("og_points") or run.original_gravity()
|
||||
|
||||
plan.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": canonical,
|
||||
"og_points": og,
|
||||
"tap_number": metadata.get("tap_number"),
|
||||
"notes": metadata.get("notes", ""),
|
||||
"readings": len(run.readings),
|
||||
"active": run.is_active(),
|
||||
"existing_name": metadata.get("name"),
|
||||
"would_change": not metadata.get("name"),
|
||||
})
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="write the changes (default is a dry run)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="overwrite names that have already been set")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
plan = build_plan(repo)
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
if not plan:
|
||||
print("No runs found in the readings collection — nothing to do.")
|
||||
return 0
|
||||
|
||||
print(f"{'run':>5} {'name':<38} {'OG':>5} {'readings':>8} state")
|
||||
print("-" * 78)
|
||||
for entry in plan:
|
||||
state = "active" if entry["active"] else "finished"
|
||||
marker = " " if entry["would_change"] else "*"
|
||||
print(f"{entry['beer_run_id']:>5} {entry['name'][:38]:<38} "
|
||||
f"{entry['og_points'] or '-':>5} {entry['readings']:>8} {state}{marker}")
|
||||
|
||||
changed = [e for e in plan if e["would_change"] or args.force]
|
||||
kept = [e for e in plan if not e["would_change"] and not args.force]
|
||||
|
||||
print()
|
||||
print(f"{len(changed)} run(s) would be written, {len(kept)} kept as they are.")
|
||||
if kept:
|
||||
print("(* already has a name set by hand — use --force to overwrite)")
|
||||
print(f"{'APPLYING' if args.apply else 'DRY RUN — nothing written'}")
|
||||
print()
|
||||
|
||||
if not args.apply:
|
||||
print("Re-run with --apply to write these.")
|
||||
return 0
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
for entry in changed:
|
||||
repo.upsert_run_metadata_partial(
|
||||
entry["beer_run_id"],
|
||||
name=entry["name"],
|
||||
og_points=entry["og_points"],
|
||||
tap_number=entry["tap_number"],
|
||||
notes=entry["notes"] or None,
|
||||
)
|
||||
print(f" wrote run {entry['beer_run_id']}: {entry['name']}")
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
print(f"\nDone — {len(changed)} run(s) written to the beer_runs collection.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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">
|
||||
@@ -64,6 +113,7 @@
|
||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -170,6 +170,15 @@ def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
||||
# ----------------------------------------------------------------- misc #
|
||||
|
||||
|
||||
def test_the_chart_library_is_loaded_exactly_once(logged_in):
|
||||
"""Regression: the chart script was included both in the base template
|
||||
and again by each page, so every chart was drawn twice."""
|
||||
for path in ("/brews", "/active", "/history"):
|
||||
body = logged_in.get(path).data.decode()
|
||||
assert body.count("js/charts.js") == 1, path
|
||||
assert body.count("chart.umd.js") == 1, path
|
||||
|
||||
|
||||
def test_static_assets_are_served(client):
|
||||
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||
"/static/img/logo.svg"):
|
||||
@@ -303,3 +312,164 @@ def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||
importlib.reload(app_factory)
|
||||
|
||||
|
||||
# ------------------------------------------------- active beers and metadata #
|
||||
|
||||
def test_active_page_shows_only_unfinished_runs(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Finished Beer", "1050", "2026-01-01", False, ""),
|
||||
("1", "Finished Beer", "1010", "2026-01-20", True, ""),
|
||||
("2", "Still Brewing", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/active")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Still Brewing" in body
|
||||
assert "Finished Beer" not in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_tap_number(logged_in, repo):
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
repo.upsert_run_metadata("2", tap_number=4)
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Tap 4" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_projected_alcohol(logged_in, repo):
|
||||
"""With two readings the first is the OG, so the estimate is meaningful."""
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, ""),
|
||||
("2", "Still Brewing", "1020", "2026-02-10", False, ""),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
# 30 point drop = 3.94%
|
||||
assert "3.94%" in body
|
||||
|
||||
|
||||
def test_active_page_shows_a_dash_when_no_og_is_known(logged_in, repo):
|
||||
"""A single reading with no recorded OG has no reliable estimate."""
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "needs 2nd reading" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_latest_comment(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, "Hops in"),
|
||||
("2", "Still Brewing", "1030", "2026-02-10", False, "Almost there"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Almost there" in body
|
||||
|
||||
|
||||
def test_run_metadata_is_saved_and_surfaced(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
|
||||
logged_in.post("/runs/8", data={
|
||||
"name": "Canonical Name",
|
||||
"tap_number": "3",
|
||||
"og_points": "1050",
|
||||
"notes": "Starter was a 50F mess.",
|
||||
}, follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
assert meta["name"] == "Canonical Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["og_points"] == 1050
|
||||
assert meta["notes"] == "Starter was a 50F mess."
|
||||
|
||||
# The canonical name must appear on the brew-process page and the active page.
|
||||
assert "Canonical Name" in logged_in.get("/brews").data.decode()
|
||||
assert "Canonical Name" in logged_in.get("/active").data.decode()
|
||||
|
||||
|
||||
def test_run_metadata_can_be_cleared(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
repo.upsert_run_metadata("8", name="Set Name", tap_number=2)
|
||||
|
||||
logged_in.post("/runs/8", data={"name": "", "tap_number": "", "og_points": "", "notes": ""},
|
||||
follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
# The name was cleared, so it falls back to the reading-derived name.
|
||||
assert not meta.get("name")
|
||||
assert meta.get("tap_number") is None
|
||||
|
||||
|
||||
def test_the_stored_name_overrides_the_drifting_reading_name(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("9", "Old Name", "1050", "2026-03-01", False, ""),
|
||||
("9", "Newer Name", "1040", "2026-03-10", False, ""),
|
||||
])
|
||||
repo.upsert_run_metadata("9", name="The Name I Want")
|
||||
|
||||
body = logged_in.get("/brews").data.decode()
|
||||
assert "The Name I Want" in body
|
||||
|
||||
|
||||
def test_next_free_tap_number_skips_the_ones_in_use(repo):
|
||||
assert repo.next_free_tap_number() == 1
|
||||
|
||||
repo.upsert_run_metadata("1", tap_number=1)
|
||||
assert repo.next_free_tap_number() == 2
|
||||
|
||||
repo.upsert_run_metadata("2", tap_number=2)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
# A gap is filled before extending the range.
|
||||
repo.upsert_run_metadata("3", tap_number=4)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
|
||||
def test_history_page_shows_completed_brews(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/history")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "5.25%" in body # standard ABV for a 40 point drop
|
||||
assert "Legacy" in body # the comparison table is present
|
||||
|
||||
|
||||
def test_history_page_flags_an_implausible_reading(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "900", "2026-01-20", True, "typo?"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
assert "worth a second look" in body
|
||||
assert "typo?" in body
|
||||
|
||||
|
||||
def test_partial_metadata_update_leaves_other_fields_alone(repo):
|
||||
"""The backfill uses this: it must not wipe a tap number the operator
|
||||
has already set."""
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3, notes="Some notes")
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name="A Better Name")
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Better Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["notes"] == "Some notes"
|
||||
|
||||
|
||||
def test_partial_metadata_update_ignores_none(repo):
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3)
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name=None, tap_number=None)
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Name"
|
||||
assert meta["tap_number"] == 3
|
||||
|
||||
+40
-3
@@ -184,17 +184,36 @@ def test_run_is_active_until_a_final_reading_exists():
|
||||
assert final_reading(finished.readings) is not None
|
||||
|
||||
|
||||
def test_run_display_name_prefers_the_most_used_label():
|
||||
"""The stored beer_type drifts across readings; the most-used one wins."""
|
||||
def test_run_display_name_prefers_the_name_on_the_final_reading():
|
||||
"""The stored beer_type drifts across readings. The name on the final
|
||||
reading is the one the operator settled on."""
|
||||
run = Run.from_docs("4", [
|
||||
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
||||
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
|
||||
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
||||
])
|
||||
# All three are unique, so the most recent wins.
|
||||
assert run.display_name() == "Nocturna Christmas beer"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_most_used_label():
|
||||
"""Without a final reading, the most-used name wins, breaking ties
|
||||
towards the most recent."""
|
||||
run = Run.from_docs("5", [
|
||||
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
|
||||
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
|
||||
])
|
||||
# The final reading's name wins.
|
||||
assert run.display_name() == "Going Nowhere fast"
|
||||
|
||||
run2 = Run.from_docs("6", [
|
||||
_doc(date="2024-12-03", beer_type="Balter"),
|
||||
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
|
||||
_doc(date="2024-12-10", beer_type="Balter"),
|
||||
])
|
||||
# No final reading; "Balter" was used twice.
|
||||
assert run2.display_name() == "Balter"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_run_number():
|
||||
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||
assert run.display_name() == "Run 7"
|
||||
@@ -305,3 +324,21 @@ def test_run_summary_handles_an_empty_run():
|
||||
assert summary["abv"] is None
|
||||
assert summary["reading_count"] == 0
|
||||
assert summary["name"] == "Run 99"
|
||||
|
||||
|
||||
def test_point_scale_is_the_digits_after_the_decimal():
|
||||
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
|
||||
reading and must not be labelled as a point count."""
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1039"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_decimal"] == "1.039"
|
||||
assert summary["min_sg_point_scale"] == "39"
|
||||
|
||||
|
||||
def test_point_scale_for_a_round_reading():
|
||||
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_point_scale"] == "10"
|
||||
assert summary["min_sg_decimal"] == "1.010"
|
||||
|
||||
Reference in New Issue
Block a user