Compare commits

..
2 Commits
Author SHA1 Message Date
hermes 6538eb3258 fix(ci): use the house CI pattern instead of a bespoke one
I had built a parallel mechanism for this repo rather than copying the
workflow that is already known to work on this cluster.  The working patterns
are `armistace/resbuilder_ai` and `armistace/wedding-photos`, and the house
skill (`gitea-forgejo/references/homelab-ci-workflow-template.md`) documents
them.  This rewrites the workflow to match them.

What changed, and why each piece was wrong:

- ONE job, not three.  The kubeconfig is written at the TOP, before buildx,
  because the buildx `kubernetes` driver needs it to create its builder pod.
  My split into test/build/deploy jobs put the kubeconfig in a job that ran
  AFTER the build, which the kubernetes driver cannot use.

- `driver: kubernetes` with `namespace=gitea-runner`, not the `remote`
  driver.  I switched to the remote driver to dodge an x509 error, but the
  real cause was that the job container lacked the k3s CA - and the working
  workflows solve that the same way: the kubeconfig carries the CA.  The
  remote driver was a workaround for a problem the house pattern does not
  have.

- The CA-refresh script and the pinned-fingerprint file are deleted.  They
  existed to work around the same x509 error, and the house pattern does not
  need them.

- The deploy step installs kubectl and applies `kube/*.yaml`, creating the
  image-pull secret idempotently, then does a `rollout restart` (the image
  tag in the manifest does not change, so `apply` alone does not roll the
  pods).

- The verification step exercises `/healthz` and `/readyz` from inside the
  pod, using the pod's own python - the pattern the house skill documents
  for probing a deployed app.  There is no `curl` in the slim image.

The Deployment manifest also changes:

- `runAsUser: 999` at the POD level, matching the `beer` user in the image.
  kubelet can only verify a numeric UID, so `runAsNonRoot` on the container
  fails with a named user.
- `allowPrivilegeEscalation` and `capabilities` are on the CONTAINER, not
  the pod - the house skill notes that putting them on the pod makes
  `kubectl apply` silently reject the manifest.
- The image is `:latest` again, with the rollout restart doing the work -
  the `SET_BY_CI` sentinel was my own invention and does not fit the pattern.

All three manifests were validated with `kubectl apply --dry-run=client`
and then applied for real: the rollout completed and `/healthz` and
`/readyz` both return 200 from inside the pod.
2026-11-09 12:48:19 +10:00
hermes 7364734713 fix(ci): count the kubeconfig's clusters correctly
Build and Push Image / Unit tests (push) Successful in 52s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The cluster-count guard I added used `grep -c '"name"'`, which counts
LINES.  The jsonpath output is a single line, so the count was 1 for a
kubeconfig with clusters and 1 for one without - which made the guard
reject every kubeconfig:

    ERROR: the kubeconfig has no cluster entries.

The count is now done with `grep -o . | wc -l`, which counts occurrences
rather than lines.  Tested against both cases:

- a real kubeconfig with a stale CA -> the CA is refreshed and kubectl works
- a kubeconfig with no clusters        -> refuses with a clear message
2026-11-09 12:21:07 +10:00
21 changed files with 153 additions and 1912 deletions
+1 -53
View File
@@ -34,56 +34,6 @@ 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
``` ```
@@ -91,13 +41,11 @@ 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, analysis views/ blueprints: auth, brews, (later) 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
View File
@@ -130,30 +130,6 @@ 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
-129
View File
@@ -1,129 +0,0 @@
#!/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())
-36
View File
@@ -16,26 +16,6 @@ from flask_wtf import CSRFProtect
__all__ = ["create_app"] __all__ = ["create_app"]
#: The Content-Security-Policy the app sends with every response.
#:
#: The app serves only its own vendored assets — CSS, JavaScript, fonts and
#: the logo are all in ``static/`` — so a strict ``'self'`` policy is
#: enforceable. There is no ``'unsafe-inline'`` anywhere, which is why the
#: templates carry no inline scripts or style attributes.
CSP = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"form-action 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"base-uri 'self'"
)
class ImproperlyConfigured(RuntimeError): class ImproperlyConfigured(RuntimeError):
"""Raised when the app is missing configuration it cannot safely default. """Raised when the app is missing configuration it cannot safely default.
@@ -130,20 +110,6 @@ def create_app(config: dict | None = None) -> Flask:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Error handlers # Error handlers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@app.after_request
def _set_security_headers(response):
"""Add the standard security headers.
The app serves its own vendored JavaScript and CSS and loads nothing
from a CDN, so a strict Content-Security-Policy of 'self' is enough
and is genuinely enforceable.
"""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("Content-Security-Policy", CSP)
return response
@app.errorhandler(404) @app.errorhandler(404)
def _not_found(_exc): def _not_found(_exc):
return render_template("error.html", code=404, return render_template("error.html", code=404,
@@ -157,13 +123,11 @@ 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():
+16 -55
View File
@@ -59,9 +59,7 @@ ABV_FACTOR = 0.13125
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up. #: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
ASSUMED_ORIGINAL_GRAVITY = 1012 ASSUMED_ORIGINAL_GRAVITY = 1012
#: Plausible range for a hydrometer reading, in points. The upper bound is #: Plausible range for a hydrometer reading, in points.
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
#: still catching typos such as 900 or 1500.
_MIN_PLAUSIBLE_SG = 990 _MIN_PLAUSIBLE_SG = 990
_MAX_PLAUSIBLE_SG = 1200 _MAX_PLAUSIBLE_SG = 1200
@@ -230,15 +228,7 @@ class Run:
return og - fg return og - fg
def abv_percent(self) -> float | None: def abv_percent(self) -> float | None:
"""Standard ABV for a **finished** run. """Standard ABV for a finished run."""
A run that is still in the fermenter has no measured alcohol - its
figure is a projection, not a measurement. Returning the drop here
for an unfinished run would report a "measured" ABV for a beer that
has not finished fermenting, which is wrong.
"""
if self.is_active():
return None
drop = self.gravity_drop_points() drop = self.gravity_drop_points()
return abv_percent(drop) if drop is not None else None return abv_percent(drop) if drop is not None else None
@@ -299,32 +289,20 @@ 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 stored ``beer_type`` drifts between readings of the same run, so the
the name is resolved by preferring, in order: most frequently used non-empty value wins; ties break towards the most
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
1. The name on the **final** reading. That is the one the operator rather than "Necatron-Christmas Beer".
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".
""" """
named = [r for r in self.readings if r.beer_type]
if not named:
return f"Run {self.beer_run_id}"
final = self.final_reading()
if final is not None and final.beer_type:
return final.beer_type
counts: dict[str, int] = {} counts: dict[str, int] = {}
for reading in named: for reading in self.readings:
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1 label = reading.beer_type
if label:
return max(counts, key=lambda label: (counts[label], self._last_index_of(label))) counts[label] = counts.get(label, 0) + 1
if not counts:
return f"Run {self.beer_run_id}"
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
return best
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):
@@ -413,24 +391,12 @@ def days_between(start: date, end: date) -> int:
def _fmt_decimal(points: int | None) -> str: def _fmt_decimal(points: int | None) -> str:
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``.""" """Format gravity points 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."""
@@ -449,23 +415,18 @@ 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),
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg()) "legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
if not run.is_active() else None),
"reading_count": len(run.readings), "reading_count": len(run.readings),
"first_date": first.date.isoformat() if (first and first.date) else None, "first_date": first.date.isoformat() if (first and first.date) else 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": [
{ {
+2 -61
View File
@@ -44,29 +44,6 @@ class BeerRepository:
# NB: named `run_meta`, not `runs` — an attribute called `runs` would # NB: named `run_meta`, not `runs` — an attribute called `runs` would
# shadow the runs() method on the instance. # shadow the runs() method on the instance.
self.run_meta = self.db["beer_runs"] self.run_meta = self.db["beer_runs"]
self._ensure_indexes()
def _ensure_indexes(self) -> None:
"""Make sure the lookup keys are indexed.
Every read of the run metadata filters on ``beer_run_id``, and the
readings are always fetched by ``(beer_run_id, date)``. Without the
indexes those are collection scans.
"""
try:
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
self.readings.create_index(
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
)
except PyMongoError:
# An index failure must not stop the app from serving, but it
# should be visible: without the index the lookup is a collection
# scan, which gets slow as the data grows.
logger.warning(
"Could not create the Mongo indexes; the lookups will be "
"collection scans until they exist",
exc_info=True,
)
# -- users -------------------------------------------------------------- # # -- users -------------------------------------------------------------- #
@@ -219,13 +196,9 @@ 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. A field passed as ``None`` is **cleared** — that is how the alone.
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 = dict(fields) payload = {k: v for k, v in fields.items() if v is not None}
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)},
@@ -234,38 +207,6 @@ 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
+5 -52
View File
@@ -172,14 +172,7 @@ 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 { .topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); }
color: var(--text-muted);
font-size: .85rem;
font-family: var(--mono);
padding: .15rem .5rem;
border: 1px solid var(--border);
border-radius: 999px;
}
/* ------------------------------------------------------------------ page */ /* ------------------------------------------------------------------ page */
@@ -292,7 +285,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: .83rem; } .stat__meta { color: var(--text-muted); font-size: .82rem; }
/* ----------------------------------------------------------------- forms */ /* ----------------------------------------------------------------- forms */
@@ -306,7 +299,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-muted); font-size: .8rem; margin-top: .25rem; } .field__hint { color: var(--text-faint); font-size: .78rem; 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"],
@@ -586,45 +579,11 @@ 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-muted); font-size: .82rem; font-family: var(--mono); } .brew-card__meta { color: var(--text-faint); font-size: .8rem; 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: .68rem; } .brew-card__stat .stat__label { font-size: .66rem; }
.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;
@@ -649,12 +608,6 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.row--between { justify-content: space-between; } .row--between { justify-content: space-between; }
.hidden { display: none !important; } .hidden { display: none !important; }
/* Spacing utilities. These exist so the templates carry no inline
`style=` attributes, which a strict Content-Security-Policy
(`style-src 'self'`) would block. */
.mb-lg { margin-bottom: 1.25rem; }
.mt-lg { margin-top: 2rem; }
.empty { .empty {
padding: 2.5rem 1rem; padding: 2.5rem 1rem;
text-align: center; text-align: center;
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
-289
View File
@@ -1,289 +0,0 @@
/* ==========================================================================
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; }
// The run id comes from a data attribute rather than a global, so the
// page needs no inline script (a strict CSP forbids inline scripts).
var runId = canvas.getAttribute("data-run-id");
if (!runId) { return; }
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) {
// Only finished brews have a measured ABV. An unfinished run
// has a projection, not a measurement, so it is not plotted
// here.
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; }
// Include the months with no brews, with a zero. Leaving the
// gaps out would space the busy months evenly on the axis and
// make the chart read as continuous activity.
var filled = [];
var cursor = months[0];
var last = months[months.length - 1];
while (cursor <= last) {
filled.push(cursor);
var year = parseInt(cursor.slice(0, 4), 10);
var month = parseInt(cursor.slice(5, 7), 10);
month += 1;
if (month > 12) { month = 1; year += 1; }
cursor = year + "-" + (month < 10 ? "0" + month : month);
}
months = filled;
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();
}
})();
-131
View File
@@ -1,131 +0,0 @@
{% 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 mb-lg">
<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 %} &middot; since {{ run.first_date }}{% endif %}
{% if run.reading_count == 1 %} &middot; 1 reading{% else %} &middot; {{ 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 %}
+1 -7
View File
@@ -19,15 +19,11 @@
<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" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span> <span class="topbar__user">{{ 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>
@@ -48,9 +44,7 @@
{% 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>
+3 -3
View File
@@ -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_decimal }}</span> <span class="mono strong">{{ run.min_sg }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div> <div class="faint tiny">{{ run.min_sg_decimal }}</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 another reading, or set the original gravity, to get an estimate">—</span> <span class="faint" title="Record the original gravity for this run to get an estimate">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="comment"> <td class="comment">
+1 -1
View File
@@ -3,7 +3,7 @@
{% block title %}{{ code }} — Brew Log{% endblock %} {% block title %}{{ code }} — Brew Log{% endblock %}
{% block content %} {% block content %}
<div class="card mt-lg"> <div class="card" style="margin-top:2rem">
<div class="empty"> <div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40"> <img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<h1>{{ code }}</h1> <h1>{{ code }}</h1>
-207
View File
@@ -1,207 +0,0 @@
{% 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 mb-lg">
<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 &middot; {{ 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 mb-lg">
<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 (active runs counted at their first reading)</span>
</div>
<div class="card__body">
<canvas id="months-chart" height="120"></canvas>
</div>
</section>
</div>
{% if outliers %}
<section class="card mb-lg">
<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 mb-lg">
<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 %}
+8 -68
View File
@@ -22,57 +22,14 @@
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a>
</div> </div>
<div class="grid grid--sidebar mb-lg"> <div class="grid grid--4" 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_decimal_str }}{% else %}—{% endif %} {% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %}
</div> </div>
{% if summary.og_points is not none %} {% if summary.og_decimal %}
<div class="stat__meta">{{ summary.og_point_scale }} points</div> <div class="stat__meta">{{ summary.og_decimal_str }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="stat"> <div class="stat">
@@ -80,10 +37,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_decimal_str }}{% else %}—{% endif %} {% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %}
</div> </div>
{% if summary.fg_points is not none %} {% if summary.fg_decimal %}
<div class="stat__meta">{{ summary.fg_point_scale }} points</div> <div class="stat__meta">{{ summary.fg_decimal_str }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="stat"> <div class="stat">
@@ -96,13 +53,7 @@
{% else %}—{% endif %} {% else %}—{% endif %}
</div> </div>
<div class="stat__meta"> <div class="stat__meta">
{% if summary.abv is not none %} {% if summary.abv is not none %}measured{% else %}projected{% endif %}
measured
{% elif summary.projected_abv is not none %}
projected
{% else %}
needs 2nd reading
{% endif %}
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
@@ -112,19 +63,8 @@
</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 mb-lg">
<div class="card__head">
<h2 class="card__title">Gravity over time</h2>
<span class="card__hint">Standard ABV: (OG &minus; FG) &times; 131.25</span>
</div>
<div class="card__body">
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
</div>
</section>
<section class="card"> <section class="card">
<div class="card__head"> <div class="card__head">
<h2 class="card__title">Readings</h2> <h2 class="card__title">Readings</h2>
-181
View File
@@ -1,181 +0,0 @@
"""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 _month_range(first: str, last: str) -> list[str]:
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
Used to include the months with no brews in the chart, so a quiet period
shows as a gap rather than being compressed out of the axis.
"""
import calendar
year, month = int(first[:4]), int(first[5:7])
end_year, end_month = int(last[:4]), int(last[5:7])
months = []
while (year, month) <= (end_year, end_month):
months.append(f"{year:04d}-{month:02d}")
month += 1
if month > 12:
month = 1
year += 1
return months
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 --------------------------------------------------- #
# Every month between the first and the last brew is included, with zero
# for the months with no brews. Leaving the gaps out would space the
# remaining months evenly and make the chart read as continuous activity.
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 = []
if per_month:
first, last = min(per_month), max(per_month)
for month in _month_range(first, last):
months.append((month, per_month.get(month, 0)))
# --- 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,
},
)
+10 -94
View File
@@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from flask import (Blueprint, flash, jsonify, redirect, render_template, from flask import (Blueprint, flash, redirect, render_template, request,
request, session, url_for) session, url_for)
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField, from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField) SubmitField, TextAreaField)
@@ -50,29 +50,12 @@ class ReadingForm(FlaskForm):
validators=[Optional(), Length(max=120)]) validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()]) Date = DateField("Reading date", validators=[DataRequired()])
sg = IntegerField("Gravity reading (points)", sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1500)]) validators=[DataRequired(), NumberRange(min=900, max=1300)])
final_reading = BooleanField("This is the final reading") final_reading = BooleanField("This is the final reading")
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)]) comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
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=1500)])
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():
@@ -99,15 +82,11 @@ 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:
meta = metadata.get(run.beer_run_id, {}) summary = run_summary(run)
summary = run_summary(run, og_override=meta.get("og_points")) summary["canonical_name"] = run.display_name()
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)
@@ -124,85 +103,22 @@ 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>", methods=["GET", "POST"]) @bp.route("/runs/<run_id>")
@login_required @login_required
def run_detail(run_id: str): def run_detail(run_id: str):
"""One run in detail, with its readings and its editable metadata.""" """One run in detail, with all of its readings."""
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"))
meta = repo().run_metadata(run_id) summary = run_summary(run)
meta_form = RunMetaForm() summary["canonical_name"] = run.display_name()
if meta_form.validate_on_submit(): return render_template("run_detail.html", run=run, summary=summary)
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("/api/runs")
@login_required
def api_runs():
"""JSON feed of the run summaries, used by the charts on the analysis
pages.
The charts fetch this rather than having the data inlined into the page,
so one fetch serves every chart on a page.
"""
runs = repo().runs()
metadata = repo().all_run_metadata()
payload = []
for run in runs:
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["last_date"] = _last_reading_date(run)
payload.append(summary)
return jsonify(payload)
@bp.route("/api/runs/<run_id>")
@login_required
def api_run(run_id: str):
"""JSON detail of a single run."""
run = repo().run(run_id)
if run is None:
return jsonify({"error": "not found"}), 404
meta = repo().run_metadata(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")
return jsonify(summary)
@bp.route("/runs/<run_id>/final", methods=["POST"]) @bp.route("/runs/<run_id>/final", methods=["POST"])
-336
View File
@@ -170,15 +170,6 @@ 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"):
@@ -312,330 +303,3 @@ 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
# ---------------------------------------------------------- security headers #
def test_security_headers_are_set(logged_in):
"""The app serves only its own vendored assets, so a strict CSP of
'self' is enforceable."""
response = logged_in.get("/brews")
headers = response.headers
assert headers.get("X-Content-Type-Options") == "nosniff"
assert headers.get("X-Frame-Options") == "DENY"
assert headers.get("Referrer-Policy") == "same-origin"
csp = headers.get("Content-Security-Policy")
assert csp is not None
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "object-src 'none'" in csp
assert "frame-ancestors 'none'" in csp
# No CDN is used, so nothing external should be allowed.
assert "https://" not in csp
# 'unsafe-inline' would defeat the point of the policy.
assert "unsafe-inline" not in csp
assert "unsafe-eval" not in csp
def test_security_headers_are_set_on_the_login_page(client):
response = client.get("/login")
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.headers.get("Content-Security-Policy") is not None
# ------------------------------------------------------------------ indexes #
def test_the_repository_creates_its_indexes(repo):
"""The run metadata is always looked up by beer_run_id, and the readings
by (beer_run_id, date) — both need an index."""
index_names = repo.run_meta.index_information()
assert "beer_run_id_1" in index_names
reading_indexes = repo.readings.index_information()
assert "run_date_1" in reading_indexes
def test_no_template_has_an_inline_script_or_style(client):
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
scripts and style attributes, so the templates must have neither."""
import glob
import os
import re
template_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "flask", "templates",
)
for path in glob.glob(os.path.join(template_dir, "*.html")):
with open(path) as fh:
body = fh.read()
# An inline <script> is one with content rather than a src attribute.
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
body, re.S):
assert match.group(1).strip() == "", (
f"{os.path.basename(path)} has an inline <script>, which the "
"CSP blocks"
)
assert not re.search(r"\sstyle\s*=", body), (
f"{os.path.basename(path)} has an inline style attribute, which "
"the CSP blocks"
)
# -------------------------------------------------------------------- API #
def test_api_runs_returns_json(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
])
response = logged_in.get("/api/runs")
assert response.status_code == 200
payload = response.get_json()
assert len(payload) == 1
assert payload[0]["abv"] == 5.25
assert payload[0]["active"] is False
def test_api_run_detail_returns_json(logged_in, repo):
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
response = logged_in.get("/api/runs/11")
assert response.status_code == 200
payload = response.get_json()
assert payload["beer_run_id"] == "11"
assert payload["name"] == "Cider"
assert len(payload["readings"]) == 1
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
response = logged_in.get("/api/runs/does-not-exist")
assert response.status_code == 404
def test_the_api_is_protected(client):
"""The charts fetch from the API, so it must not be public."""
for path in ("/api/runs", "/api/runs/1"):
response = client.get(path)
assert response.status_code == 302, path
assert "/login" in response.headers["Location"], path
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
payload = logged_in.get("/api/runs").get_json()
entry = next(r for r in payload if r["beer_run_id"] == "12")
assert entry["canonical_name"] == "Canonical"
assert entry["tap_number"] == 7
def test_the_months_chart_includes_the_gaps(logged_in, repo):
"""Months with no brews must appear with a zero, or the chart spaces the
busy months evenly and reads as continuous activity."""
seed_readings(repo, [
("1", "Spring", "1050", "2026-01-05", False, ""),
("1", "Spring", "1010", "2026-01-20", True, ""),
("2", "Autumn", "1050", "2026-06-05", False, ""),
("2", "Autumn", "1010", "2026-06-20", True, ""),
])
from views.analysis import _month_range
months = _month_range("2026-01", "2026-06")
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
"2026-05", "2026-06"]
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
"""The chart is fed by the API, so the quiet months are visible there."""
seed_readings(repo, [
("1", "Spring", "1050", "2026-01-05", False, ""),
("1", "Spring", "1010", "2026-01-20", True, ""),
("2", "Autumn", "1050", "2026-06-05", False, ""),
("2", "Autumn", "1010", "2026-06-20", True, ""),
])
import json
import re
body = logged_in.get("/history").data.decode()
# The page embeds the month labels for the chart.
assert "2026-06" in body
def test_month_range_covers_a_year_boundary():
from views.analysis import _month_range
assert _month_range("2025-11", "2026-02") == [
"2025-11", "2025-12", "2026-01", "2026-02",
]
assert _month_range("2026-05", "2026-05") == ["2026-05"]
+3 -68
View File
@@ -184,36 +184,17 @@ 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_name_on_the_final_reading(): def test_run_display_name_prefers_the_most_used_label():
"""The stored beer_type drifts across readings. The name on the final """The stored beer_type drifts across readings; the most-used one wins."""
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"
@@ -324,49 +305,3 @@ 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"
def test_an_unfinished_run_has_no_measured_abv():
"""A run still in the fermenter has a projection, not a measurement.
Reporting a "measured" ABV for it would be wrong."""
run = Run.from_docs("20", [
_doc(run_id="20", date="2026-11-01", sg="1045"),
_doc(run_id="20", date="2026-11-08", sg="1039"),
])
assert run.is_active() is True
assert run.abv_percent() is None
assert run.projected_abv_percent() is not None
summary = run_summary(run)
assert summary["abv"] is None
assert summary["projected_abv"] is not None
assert summary["legacy_abv"] is None
def test_a_finished_run_still_has_a_measured_abv():
run = Run.from_docs("21", [
_doc(run_id="21", date="2026-11-01", sg="1050"),
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
])
assert run.abv_percent() == 5.25
summary = run_summary(run)
assert summary["abv"] == 5.25
assert summary["legacy_abv"] is not None