Files
beer_data/src/flask/static/js/charts.js
T
hermes bbd9fe17e5 fix(analysis): exclude suspect runs from every average, not just attenuation
Review finding, and it was right. The PR claimed that runs with impossible
readings were "excluded from the averages". That was only true of the
attenuation average. Run 12's bad final gravity was still poisoning three
other statistics:

- **Brew time** - its 56 days were folded into the average, making a
  typical brew look like 13.7 days when it is 10.9.
- **Time to 1.010** - its 56 days did the same to that figure (8.4d).
- **The history page's ABV** - an 18.64% ABV, which made run 12 the
  "strongest" beer in the history and pulled the average up.
- **The fermentation curve** - its curve plunges from 1.042 to 0.900, which
  dragged the median line down and stretched the min-max envelope to cover
  a gravity below water.

One bad reading was reaching four different statistics.

What changed
------------
- The suspect runs are computed once and excluded from every aggregate:
  the attenuation, brew-time and time-to-1.010 averages, the fastest and
  slowest brews, the history page's ABV average, and its strongest and
  lightest.
- `/api/runs` now carries a `suspect` flag, and the charts skip the flagged
  runs - the attenuation chart and the fermentation curve.
- The suspect run stays in the table, with a "not counted" chip and a tinted
  row, so it can be corrected.  It is excluded from the figures, not
  hidden.  The card header says "16 brews (15 counted)" and the prose says
  how many brews the figures are based on.

Tests
-----
133 (up from 127).  New coverage: the suspect run is excluded from the brew
time and time-to-1.010 averages, it is still listed for review, it is
flagged in the API, it is not the strongest brew in the history, and the
charts filter it out.

The `logged_in` fixture moved to conftest.py, where both test modules can
use it.
2026-11-09 14:03:07 +10:00

587 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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: {
x: {
// The dates are strings, so this axis stays
// categorical - but it must be declared,
// because Chart.js would otherwise infer
// something else from the {x, y} points.
type: "category",
ticks: { maxRotation: 0, autoSkipPadding: 12 }
},
y: {
// NOT reversed - see the fermentation curve.
// Gravity falls as the beer ferments, so the
// line must descend left to right.
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: {
// The names are long and the chart is narrow,
// so the labels are trimmed above and
// rotated. autoSkip keeps them from
// overlapping; the tooltip carries the
// full name for every bar.
ticks: {
maxRotation: 45,
minRotation: 45,
autoSkip: true,
autoSkipPadding: 8
}
}
}
})
});
})
.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); });
}
/* ------------------------------------------------ attenuation by brew */
function drawAttenuationChart() {
var canvas = document.getElementById("attenuation-chart");
if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) {
// A run with an impossible reading has no attenuation, and
// is flagged as suspect. Both are excluded, so one bad
// reading cannot become a bar in the chart.
var done = runs.filter(function (r) {
return !r.suspect &&
r.attenuation !== null && r.attenuation !== undefined;
});
if (!done.length) { return; }
new Chart(canvas.getContext("2d"), {
type: "bar",
data: {
labels: done.map(function (r) {
var name = r.canonical_name || ("Run " + r.beer_run_id);
// Long names were clipped at the left edge of
// the plot when rotated. Trim them to a
// readable length; the tooltip and the table
// below carry the full name.
if (name.length > 18) {
name = name.slice(0, 17) + "\u2026";
}
return name;
}),
datasets: [{
label: "Attenuation %",
data: done.map(function (r) { return r.attenuation; }),
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(1) + "% attenuated";
}
}
}
},
scales: {
y: {
beginAtZero: true,
// Attenuation cannot exceed 100%, so the axis
// is capped there. Without the cap a single
// bad reading would squash every other bar
// into an unreadable stub.
suggestedMax: 100,
title: { display: true, text: "% attenuated" }
},
x: {
// The names are long and the chart is narrow,
// so the labels are trimmed above and
// rotated. autoSkip keeps them from
// overlapping; the tooltip carries the
// full name for every bar.
ticks: {
maxRotation: 45,
minRotation: 45,
autoSkip: true,
autoSkipPadding: 8
}
}
}
})
});
})
.catch(function (err) { console.warn(err); });
}
/* -------------------------------------------- fermentation curve overlay */
function drawCurveChart() {
var canvas = document.getElementById("curve-chart");
if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) {
// A run with an impossible reading has a broken curve - it
// plunges to a gravity below water - which would drag the
// median line down and stretch the envelope to cover it.
var withCurve = runs.filter(function (r) {
return !r.suspect && r.curve && r.curve.length > 1;
});
if (!withCurve.length) { return; }
// Plotting every brew individually gives 16 near-identical
// lines and an unreadable legend. Instead: a MEDIAN curve
// with the min/max envelope behind it, which shows the
// typical shape and the spread without the clutter.
//
// Each curve is the fraction of its own gravity drop
// completed, so brews with different starting gravities can
// be compared directly.
var samples = 21; // 0%, 5%, ... 100%
// Interpolate each brew's curve onto the common progress axis.
//
// The brew's readings are at its own progress values (say
// 0%, 40%, 100%), so each sample point is interpolated
// BETWEEN the two readings that bracket it. Looking the
// value up by an exact key only works when every brew
// happened to be read at the same progress points, which is
// why the first attempt produced three points.
function interpolate(curve, pct) {
if (!curve.length) { return null; }
if (pct <= curve[0].x) { return curve[0].y; }
var last = curve[curve.length - 1];
if (pct >= last.x) { return last.y; }
for (var k = 0; k < curve.length - 1; k++) {
var a = curve[k];
var b = curve[k + 1];
if (pct >= a.x && pct <= b.x) {
var span = b.x - a.x;
if (span === 0) { return a.y; }
return a.y + (b.y - a.y) * ((pct - a.x) / span);
}
}
return last.y;
}
// The raw readings of each brew, sorted by progress.
var rawCurves = withCurve.map(function (r) {
var points = r.curve.map(function (p) {
return {
x: p.progress * 100,
y: parseFloat(p.sg_decimal)
};
}).filter(function (p) { return !isNaN(p.y); });
points.sort(function (a, b) { return a.x - b.x; });
return points;
}).filter(function (c) { return c.length > 1; });
if (!rawCurves.length) { return; }
// The interpolated value of every brew at every sample point.
var sampled = rawCurves.map(function (curve) {
var out = [];
for (var i = 0; i < samples; i++) {
var pct = (i / (samples - 1)) * 100;
var y = interpolate(curve, pct);
if (y === null) { continue; }
out.push({ x: pct, y: y });
}
return out;
}).filter(function (c) { return c.length > 1; });
if (!sampled.length) { return; }
// The envelope: the highest and lowest gravity at each
// progress point.
var envelope = [];
var median = [];
for (var i = 0; i < samples; i++) {
var pct = (i / (samples - 1)) * 100;
var values = [];
sampled.forEach(function (curve) {
var match = curve.filter(function (p) {
return Math.abs(p.x - pct) < 0.01;
});
if (match.length) { values.push(match[0].y); }
});
if (!values.length) { continue; }
values.sort(function (a, b) { return a - b; });
envelope.push({
x: pct,
y: values[0],
yMax: values[values.length - 1]
});
var mid = Math.floor(values.length / 2);
median.push({
x: pct,
y: values.length % 2
? values[mid]
: (values[mid - 1] + values[mid]) / 2
});
}
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
datasets: [
{
label: "Range (min–max)",
data: envelope,
borderColor: withAlpha(ACCENT, 0.35),
backgroundColor: withAlpha(ACCENT, 0.12),
borderWidth: 1,
pointRadius: 0,
fill: "+1",
tension: 0.3
},
{
label: "Median brew",
data: median,
borderColor: ACCENT_BRIGHT,
backgroundColor: withAlpha(ACCENT_BRIGHT, 0.15),
borderWidth: 2.5,
pointRadius: 3,
pointBackgroundColor: ACCENT_BRIGHT,
fill: false,
tension: 0.3
}
]
},
options: baseOptions({
plugins: {
legend: {
display: true,
labels: {
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
font: { size: 11 }
}
},
tooltip: {
callbacks: {
label: function (ctx) {
var point = ctx.parsed;
if (ctx.datasetIndex === 0) {
return "range " + point.y +
" to " + (point.yMax || point.y);
}
return "median " + point.y;
}
}
}
},
scales: {
x: {
// The x values are NUMERIC (0-100), so the
// axis must be linear. Chart.js defaults a
// line chart's x-axis to category, which
// collapses every point onto index 0 and
// renders the curve as a sliver at the
// origin.
type: "linear",
title: { display: true, text: "% of gravity drop" },
min: 0,
max: 100,
ticks: { stepSize: 10 }
},
y: {
// NOT reversed. Specific gravity falls as
// the beer ferments, and the scale puts the
// HIGH reading at the top by default, so the
// curve descends left to right - the way a
// brewer reads a fermentation. With
// reverse:true the curve climbed instead,
// which read as gravity rising.
title: { display: true, text: "Specific gravity" }
}
}
})
});
})
.catch(function (err) { console.warn(err); });
}
function init() {
drawSparklines();
drawRunChart();
drawAbvChart();
drawMonthsChart();
drawAttenuationChart();
drawCurveChart();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();