Files
beer_data/src/flask/templates/analysis.html
T
hermes 6fec78e9e4 fix(analysis): address the two review nits
The re-review returned APPROVED with two low-severity notes.

- The prose said "Across 15 finished brews", which reads as though there
  are only 15 brews.  It now says "Across 15 of 16 finished brews", so the
  exclusion is visible in the sentence itself and not only in the card
  header.

- `/api/runs/<run_id>` did not carry the `suspect` flag that
  `/api/runs` does, so a consumer of the detail endpoint could not tell an
  impossible run from a good one.  It does now, with a test.

134 tests.
2026-11-09 14:18:29 +10:00

222 lines
8.7 KiB
HTML

{% extends 'base.html' %}
{% block title %}Analysis — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Analysis</h1>
<p class="page__sub">How consistently the brews ferment, and how long each one takes.</p>
</div>
<div class="btn-row">
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
</div>
</div>
{% if not rows %}
<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>No finished brews to analyse yet.</strong></p>
<p class="small">Once a run has a final reading it appears here.</p>
</div>
</div>
{% else %}
<div class="grid grid--4 mb-lg">
<div class="stat">
<div class="stat__label">Average attenuation</div>
<div class="stat__value">
{% if stats.avg_attenuation is not none %}{{ stats.avg_attenuation }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">of the original gravity fermented</div>
</div>
<div class="stat">
<div class="stat__label">Spread</div>
<div class="stat__value">
{% if stats.consistency %}±{{ stats.consistency.stdev }}pp{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.consistency %}
standard deviation of attenuation, lower is better
{% else %}
needs at least two brews
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Average brew time</div>
<div class="stat__value">
{% if stats.avg_days is not none %}{{ stats.avg_days }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">first reading to final reading</div>
</div>
<div class="stat">
<div class="stat__label">Time to 1.010</div>
<div class="stat__value">
{% if stats.avg_days_to_1010 is not none %}{{ stats.avg_days_to_1010 }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.reached_1010 %}
average of {{ stats.reached_1010 }} brew{{ '' if stats.reached_1010 == 1 else 's' }} that got there
{% else %}
no brew reached 1.010
{% endif %}
</div>
</div>
</div>
<div class="grid grid--2 mb-lg">
<section class="card">
<div class="card__head">
<h2 class="card__title">Attenuation by brew</h2>
<span class="card__hint">How much of the original gravity was fermented away</span>
</div>
<div class="card__body">
<canvas id="attenuation-chart" height="130"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Fermentation curves</h2>
<span class="card__hint">Progress through fermentation, 0% to 100%</span>
</div>
<div class="card__body">
<canvas id="curve-chart" height="130"></canvas>
</div>
</section>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Every finished brew</h2>
<span class="card__hint">{{ stats.count }} brews{% if stats.suspect_count %} ({{ stats.trusted_count }} counted){% endif %}</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">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th class="num">Attenuation</th>
<th class="num">Days</th>
<th class="num">To 1.010</th>
<th>Finished</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="num">
{% if row.og_points is not none %}
{{ '%.3f' % (row.og_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% if suspect %}
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Readings that need a second look</h2>
<span class="card__hint">{{ suspect | length }} run{{ '' if suspect | length == 1 else 's' }} excluded from the averages</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
{% if stats.consistency %}
<section class="card">
<div class="card__head">
<h2 class="card__title">What the numbers say</h2>
</div>
<div class="card__body">
<p>
Across {% if stats.suspect_count %}{{ stats.trusted_count }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
deviation of <strong>{{ stats.consistency.stdev }} percentage
points</strong> and a total range of {{ stats.consistency.spread }}
percentage points.
{% if stats.consistency.stdev <= 3 %}
That is a tight spread — the process is repeatable.
{% elif stats.consistency.stdev <= 6 %}
That is a moderate spread — some variation between batches.
{% else %}
That is a wide spread — something in the process is not
consistent between batches.
{% endif %}
</p>
{% if stats.fastest and stats.slowest and stats.fastest != stats.slowest %}
<p>
The quickest brew was <strong>{{ stats.fastest.name }}</strong> at
{{ stats.fastest.days }} days; the longest was
<strong>{{ stats.slowest.name }}</strong> at
{{ stats.slowest.days }} days.
</p>
{% endif %}
{% if stats.avg_days_to_1010 %}
<p>
On average a brew reaches gravity 1.010 after
{{ stats.avg_days_to_1010 }} days — the number to plan around when
deciding when to check on a fermenter.
</p>
{% endif %}
</div>
</section>
{% endif %}
{% endif %}
{% endblock %}