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 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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -41,11 +91,13 @@ src/flask/
|
|||||||
beer_data.py entry point; exposes `app` and `create_app`
|
beer_data.py entry point; exposes `app` and `create_app`
|
||||||
app_factory.py application factory, request-scoped Mongo repository
|
app_factory.py application factory, request-scoped Mongo repository
|
||||||
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
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
|
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
||||||
templates/ Jinja templates
|
templates/ Jinja templates
|
||||||
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
||||||
|
static/ CSS, JS, fonts, logo (all served locally - no CDN)
|
||||||
tests/ pytest suite
|
tests/ pytest suite
|
||||||
|
scripts/ operational scripts (the run-metadata backfill)
|
||||||
```
|
```
|
||||||
|
|
||||||
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
||||||
|
|||||||
+24
@@ -130,6 +130,30 @@ livenessProbe:
|
|||||||
periodSeconds: 20
|
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
|
## Data safety
|
||||||
|
|
||||||
The `beer_db.beer_data` collection holds the actual brewing records. There is
|
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
|
# Blueprints
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
from views.analysis import bp as analysis_bp
|
||||||
from views.auth import bp as auth_bp
|
from views.auth import bp as auth_bp
|
||||||
from views.brews import bp as brews_bp
|
from views.brews import bp as brews_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(brews_bp)
|
app.register_blueprint(brews_bp)
|
||||||
|
app.register_blueprint(analysis_bp)
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
|
|||||||
+41
-13
@@ -289,20 +289,32 @@ class Run:
|
|||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
"""Best available human label for the run.
|
"""Best available human label for the run.
|
||||||
|
|
||||||
The stored ``beer_type`` drifts between readings of the same run, so the
|
The stored ``beer_type`` drifts between readings of the same run, so
|
||||||
most frequently used non-empty value wins; ties break towards the most
|
the name is resolved by preferring, in order:
|
||||||
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
|
|
||||||
rather than "Necatron-Christmas Beer".
|
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] = {}
|
named = [r for r in self.readings if r.beer_type]
|
||||||
for reading in self.readings:
|
if not named:
|
||||||
label = reading.beer_type
|
|
||||||
if label:
|
|
||||||
counts[label] = counts.get(label, 0) + 1
|
|
||||||
if not counts:
|
|
||||||
return f"Run {self.beer_run_id}"
|
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:
|
def _last_index_of(self, label: str) -> int:
|
||||||
for index in range(len(self.readings) - 1, -1, -1):
|
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:
|
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:
|
if points is None:
|
||||||
return ""
|
return ""
|
||||||
return f"{points / 1000:.3f}"
|
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,
|
def run_summary(run: Run, today: date | None = None,
|
||||||
og_override: int | None = None) -> dict:
|
og_override: int | None = None) -> dict:
|
||||||
"""A flat dict describing a run, ready for a template or a JSON response."""
|
"""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.
|
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
||||||
"og_decimal_str": _fmt_decimal(og),
|
"og_decimal_str": _fmt_decimal(og),
|
||||||
"fg_decimal_str": _fmt_decimal(fg),
|
"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(),
|
"max_sg": run.max_sg(),
|
||||||
"min_sg": run.min_sg(),
|
"min_sg": run.min_sg(),
|
||||||
"min_sg_decimal": _fmt_decimal(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(),
|
"drop_points": run.gravity_drop_points(),
|
||||||
"abv": run.abv_percent(),
|
"abv": run.abv_percent(),
|
||||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
"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,
|
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||||
"days_in_fermenter": run.days_in_fermenter(today),
|
"days_in_fermenter": run.days_in_fermenter(today),
|
||||||
"comments": run.comments(),
|
"comments": run.comments(),
|
||||||
|
"last_comment": run.comments()[-1] if run.comments() else "",
|
||||||
"outliers": [r.sg_points for r in run.outlier_readings()],
|
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||||
"readings": [
|
"readings": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -196,9 +196,13 @@ class BeerRepository:
|
|||||||
"""Create or update the metadata document for a run.
|
"""Create or update the metadata document for a run.
|
||||||
|
|
||||||
Only the keys passed in are written; anything already stored is left
|
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()
|
payload["updated_at"] = _now_iso()
|
||||||
self.run_meta.update_one(
|
self.run_meta.update_one(
|
||||||
{"beer_run_id": _as_id(beer_run_id)},
|
{"beer_run_id": _as_id(beer_run_id)},
|
||||||
@@ -207,6 +211,38 @@ class BeerRepository:
|
|||||||
)
|
)
|
||||||
return self.run_metadata(beer_run_id)
|
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:
|
def delete_run_metadata(self, beer_run_id: str) -> bool:
|
||||||
"""Remove the metadata document for a run (readings are untouched)."""
|
"""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
|
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__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 */
|
/* ------------------------------------------------------------------ page */
|
||||||
|
|
||||||
@@ -285,7 +292,7 @@ p { margin: 0 0 .75rem; }
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--amber-bright);
|
color: var(--amber-bright);
|
||||||
}
|
}
|
||||||
.stat__meta { color: var(--text-muted); font-size: .82rem; }
|
.stat__meta { color: var(--text-muted); font-size: .83rem; }
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- forms */
|
/* ----------------------------------------------------------------- forms */
|
||||||
|
|
||||||
@@ -299,7 +306,7 @@ p { margin: 0 0 .75rem; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: .3rem;
|
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; }
|
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
|
||||||
|
|
||||||
input[type="text"], input[type="password"], input[type="number"],
|
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__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__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__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__stat .stat__value { font-size: 1.3rem; }
|
||||||
.brew-card__spark { height: 46px; }
|
.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 {
|
.brew-card__spark--empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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">
|
<nav class="nav" aria-label="Main">
|
||||||
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
||||||
href="{{ url_for('brews.index') }}">Brew Process</a>
|
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>
|
</nav>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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>
|
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +48,9 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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/app.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -93,15 +93,15 @@
|
|||||||
<td>{{ run.canonical_name }}</td>
|
<td>{{ run.canonical_name }}</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{% if run.min_sg is not none %}
|
{% if run.min_sg is not none %}
|
||||||
<span class="mono strong">{{ run.min_sg }}</span>
|
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||||
<div class="faint tiny">{{ run.min_sg_decimal }}</div>
|
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{% if run.projected_abv is not none %}
|
{% if run.projected_abv is not none %}
|
||||||
{{ '%.2f' % run.projected_abv }}%
|
{{ '%.2f' % run.projected_abv }}%
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="comment">
|
<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>
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||||
</div>
|
</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">
|
||||||
<div class="stat__label">Original gravity</div>
|
<div class="stat__label">Original gravity</div>
|
||||||
<div class="stat__value">
|
<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>
|
</div>
|
||||||
{% if summary.og_decimal %}
|
{% if summary.og_points is not none %}
|
||||||
<div class="stat__meta">{{ summary.og_decimal_str }}</div>
|
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -37,10 +80,10 @@
|
|||||||
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat__value">
|
<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>
|
</div>
|
||||||
{% if summary.fg_decimal %}
|
{% if summary.fg_points is not none %}
|
||||||
<div class="stat__meta">{{ summary.fg_decimal_str }}</div>
|
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -53,7 +96,13 @@
|
|||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat__meta">
|
<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>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -63,6 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card">
|
<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")
|
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"])
|
@bp.route("/brews", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -82,11 +99,15 @@ def index():
|
|||||||
return redirect(url_for("brews.index"))
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
runs = repo().runs()
|
runs = repo().runs()
|
||||||
|
metadata = repo().all_run_metadata()
|
||||||
|
|
||||||
summaries = []
|
summaries = []
|
||||||
for run in runs:
|
for run in runs:
|
||||||
summary = run_summary(run)
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
summary["canonical_name"] = run.display_name()
|
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)
|
summary["last_date"] = _last_reading_date(run)
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
|
|
||||||
@@ -103,22 +124,46 @@ def index():
|
|||||||
summaries=summaries,
|
summaries=summaries,
|
||||||
active_runs=active,
|
active_runs=active,
|
||||||
completed_runs=completed,
|
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
|
@login_required
|
||||||
def run_detail(run_id: str):
|
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)
|
run = repo().run(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
flash(f"No run numbered {run_id}.", "error")
|
flash(f"No run numbered {run_id}.", "error")
|
||||||
return redirect(url_for("brews.index"))
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
summary = run_summary(run)
|
meta = repo().run_metadata(run_id)
|
||||||
summary["canonical_name"] = run.display_name()
|
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"])
|
@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 #
|
# ----------------------------------------------------------------- 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):
|
def test_static_assets_are_served(client):
|
||||||
for path in ("/static/js/app.js", "/static/css/app.css",
|
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||||
"/static/img/logo.svg"):
|
"/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")
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||||
importlib.reload(app_factory)
|
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
|
assert final_reading(finished.readings) is not None
|
||||||
|
|
||||||
|
|
||||||
def test_run_display_name_prefers_the_most_used_label():
|
def test_run_display_name_prefers_the_name_on_the_final_reading():
|
||||||
"""The stored beer_type drifts across readings; the most-used one wins."""
|
"""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", [
|
run = Run.from_docs("4", [
|
||||||
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
_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-28", beer_type="Nocturna the Christmas beer"),
|
||||||
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
_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"
|
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():
|
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")])
|
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||||
assert run.display_name() == "Run 7"
|
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["abv"] is None
|
||||||
assert summary["reading_count"] == 0
|
assert summary["reading_count"] == 0
|
||||||
assert summary["name"] == "Run 99"
|
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