Compare commits

...
61 Commits
Author SHA1 Message Date
hermes 8e4770f870 fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
Build and Push Image / Build and push image (push) Successful in 3m33s
fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
2026-11-09 22:41:44 +10:00
hermes fb1088ceec fix(ui): make the advice pop-out fail-safe against a stale stylesheet
The screenshot from Firefox showed the advice expanded inline in the
table cell, running down the column and off the screen, with the long
and short labels both showing ("BREWING ADVICEADVICE").  The page had
the new markup and Firefox was serving a stale stylesheet.

Two fixes.

The static files now send Cache-Control: no-store.  They sent
no-cache, which means "revalidate" - and Firefox was reusing them
without revalidating.  no-store is the only one that means "do not
keep this".  The HTML pages already sent no-store.

The pop-out carries the hidden attribute, and the script removes it
when the panel opens and puts it back when it closes.  That makes the
MARKUP fail-safe rather than the stylesheet: a stylesheet that does
not know about .advice-pop at all still leaves the panel hidden, so
the advice can never appear expanded inside the cell.

Verified by removing all eight .advice-pop rules from the live
stylesheet - the panel stays hidden, 0x0, and the advice is not
rendered.  Before this it would have rendered inline.

The button still opens it on tap and on hover, and the close button
still closes it.  The desktop is unchanged: the same full headings and
the same hover pop-out.

195 tests pass.
2026-11-09 22:41:20 +10:00
hermes cd45cc2f17 fix(advice): guard the advice write on the comment as well as the token (#21)
Build and Push Image / Build and push image (push) Successful in 3m10s
fix(advice): guard the advice write on the comment as well as the token (#21)
2026-11-09 22:27:01 +10:00
hermes e603f23928 fix(advice): guard the advice write on the comment as well as the token
The editing token catches every edit the application makes, because
upsert_reading writes a fresh token on every write.  A document edited
outside the application - by a script, or by hand - keeps its empty
token, so a stale thread's write would still match on the gravity alone
and could put advice about the old comment back.

The advice is generated from the comment as well as the gravity, so the
comment is now a second, independent guard on the write.  A stale write
can only land if BOTH the gravity and the comment are unchanged.

The stored comment and the one the view sees can differ by trailing
whitespace - two of the live readings are padded - so the match is a
stripped, anchored regex rather than an exact one.

Also: the log message said the reading 'no longer holds the gravity it
was written for', but the guard is the token and the comment.  It now
says what actually happened.

194 tests pass.
2026-11-09 22:26:29 +10:00
hermes a38febaca8 fix(advice): return matched_count, and do not advise about the wrong reading (#20)
Build and Push Image / Build and push image (push) Successful in 3m15s
fix(advice): return matched_count, and do not advise about the wrong reading (#20)
2026-11-09 22:16:46 +10:00
hermes ef0d665b78 fix(advice): return matched_count, and do not advise about the wrong reading
set_reading_advice returned modified_count, which is 0 both when the
guard refused the write and when the write matched but the text was
already identical.  The caller logs "the advice was not stored" on 0,
so it has to mean "the guard refused it".  It now returns
matched_count.

_start_advice fell back to the newest reading when the reading it was
asked about was not in the run.  That described the wrong number, and
the conditional write would refuse it anyway - so it now says so and
stops.

The editing token is the primary guard on the write, not the gravity.
Every edit writes a fresh token, so a comment-only correction - which
leaves the gravity unchanged - is caught too.  Verified against live
Mongo with the real repository code: the fresh advice lands, the slow
stale thread is refused, and the brewer's comment is untouched.

193 tests pass.
2026-11-09 22:16:29 +10:00
hermes 81ebdf3c76 fix(ui): stop the pages being cached, and the labels duplicating (#19)
Build and Push Image / Build and push image (push) Successful in 3m29s
fix(ui): stop the pages being cached, and the labels duplicating (#19)
2026-11-09 22:06:58 +10:00
hermes e473850aac fix(ui): stop the pages being cached, and the labels duplicating
Firefox served a cached copy of the brewing page after the advice
became a button and a pop-out, so the phone kept showing the old
inline advice - a long paragraph in the table cell, with the button
label reading "Read the adviceAdvice".

Two separate causes:

The pages had no cache headers, so Firefox was free to reuse them.
They now send Cache-Control: no-store, must-revalidate.

The rule that hides the short label was scoped to `table.data .short`,
so it did not apply to the advice button's label.  A stale stylesheet
then showed both forms at once.  The rule is now on `.short` alone, so
it applies wherever the pattern is used - a table heading, the advice
button, the Active Beers page - and only ever one form can show.

Verified at 390px and 1280px: the phone shows "Advice", the desktop
shows "Read the advice" and "BREWING ADVICE", and no element ever
shows both.

192 tests pass.
2026-11-09 22:06:42 +10:00
hermes 571e7cb8c8 fix(ui): keep the table columns readable on a phone (#18)
Build and Push Image / Build and push image (push) Successful in 3m20s
fix(ui): keep the table columns readable on a phone (#18)
2026-11-09 21:48:20 +10:00
hermes 27b2611a20 fix(ui): keep the table columns readable on a phone
The tables were squeezed to fit the screen, and with seven to nine
columns on a 390px phone every heading and every value was truncated to
a few characters - a gravity of "1.0..." and a date of "202..." are
worse than useless, and the beer name was painted over the gravity
reading because the cell was allowed to overflow.

The tables keep their natural width and scroll sideways inside their
card, which is what a table does.  The advice is a small button so the
column that matters is narrow, and the headings have a short form for
a phone ("Brewing advice" -> "Advice", "Attenuation" -> "Att.").

The secondary columns - the drop, the legacy ABV and the days - go away
on a phone; the details are still on the run's own page.

A name and a comment get a sensible minimum width and wrap on a word
boundary rather than one word per line.

The desktop is untouched: the same full headings, the same 806px table,
and the pop-out still opens on hover.
2026-11-09 21:48:07 +10:00
hermes 81b2a17b37 fix(ui): dimmed backdrop and a bigger close button on the advice panel (#17)
Build and Push Image / Build and push image (push) Successful in 3m39s
fix(ui): dimmed backdrop and a bigger close button on the advice panel (#17)
2026-11-09 21:14:55 +10:00
hermes 2600622af8 fix(ui): dimmed backdrop and a bigger close button on the advice panel
The panel read as floating in the page rather than sitting over it, and
the close button was a 26px target - too small to hit reliably on a
phone.

The backdrop is a large spread shadow rather than a pseudo element,
which would paint behind the panel's own background.

Also: Escape closes the panel, clicking away closes it, and the button
is a real button so it works with a keyboard.
2026-11-09 21:14:33 +10:00
hermes af28ecbd1b fix(ui): mobile pop-out for the advice, and tables that fit a phone (#16)
Build and Push Image / Build and push image (push) Successful in 12m4s
fix(ui): mobile pop-out for the advice, and tables that fit a phone (#16)
2026-11-09 20:58:06 +10:00
hermes 21fcdf485c test: the advice in the table is a pop-out, not the text 2026-11-09 20:57:34 +10:00
hermes 7dd39190f1 fix(ui): mobile pop-out for the advice, and tables that fit a phone
The brewing advice is several hundred characters.  In a table cell it
pushed the table past the width of a phone, so the column was clipped
off the right edge and the advice could not be read without sideways
scrolling.

The advice is now a compact trigger in the cell and the text is in a
floating panel that opens on hover (desktop) or on tap (phone).

The panel is position:fixed and anchored by the script.  Absolutely
positioned inside the cell it was clipped by the table's own scroll
container, and the advice was sliced in half by the card's edge.  It
flips above the trigger when there is no room below.

The tables now reflow into stacked cards on a narrow screen, with each
value labelled by its column name.  A seven column table does not fit a
phone, and it used to scroll sideways inside its wrapper, so the
columns on the right were off the screen.

Also: the header wraps onto two rows so the nav tabs are not clipped,
the point scale says 'points' rather than 'pts', and the reading count
on the beer card does not orphan its word.
2026-11-09 20:57:34 +10:00
hermes 55b6068b3b feat(advice): LLM brewing advice on every observation (#14)
Build and Push Image / Build and push image (push) Successful in 9m8s
feat(advice): LLM brewing advice on every observation (#14)
2026-11-09 20:23:32 +10:00
hermes 8de3d011db fix(advice): find the marker however the model formats it
The instruction asks for "ADVICE:", but the model writes "**ADVICE:**",
"## ADVICE:" or puts it in a code block.  The extraction has to cope,
because leftover markdown in the advice is visible to the brewer.

The marker is now matched case-insensitively and with the decoration the
model puts around it, and the leftover formatting is stripped.

    plain marker         -> 'Warm it to 20C and wait.'
    bold marker          -> 'Warm it to 20C and wait.'
    in a code block      -> 'Warm it to 20C and wait.'
    marker mid-text      -> 'Warm it to 20C and wait.'
    markdown heading     -> 'Warm it to 20C and wait.'
    lowercase marker     -> 'Warm it to 20C and wait.'
    no marker            -> 'Warm it to 20C and wait.'
    bold + code block    -> 'Warm it to 20C and wait.'

182 tests (up from 181).
2026-11-09 19:46:13 +10:00
hermes 75f94d47e2 fix(advice): a deterministic split, not a guess at where the advice starts
The stripper tried to work out where the model's working ends and the
advice begins by classifying prose - marker words, hedging words,
length.  It failed in BOTH directions, and the review caught both:

- it emptied legitimate advice (a 1,093-character reply containing one
  hedging word and no marker phrase was reduced to ""), so the brewer
  saw nothing at all; and
- it let the model's working through in full (a 1,053-character reply
  with no hedging word was returned verbatim), so the brewer was shown
  the model thinking about their beer.

Neither a marker list nor a length threshold is reliable.  Prose is not
a format.

So the model is asked for the format instead.  Its reply must have this
shape:

    ADVICE:
    <the advice itself>

Everything before the marker is working, everything from it on is the
advice.  The split is exact, with no judgement calls.

Verified against the live model, four different brews:

    Test Pale Ale        marker@pos=0  finish=stop  advice_len=656
    Nocturna Christmas   marker@pos=0  finish=stop  advice_len=668
    Ginger beer          marker@pos=0  finish=stop  advice_len=621
    Cider raspberry      marker@pos=0  finish=stop  advice_len=539

All four start clean, with no marker left in the advice.

A reply with no marker is used as it stands - the safe direction, where
the brewer sees a slightly rambling reply rather than no advice at all.

An empty advice is now recorded as a FAILURE rather than stored as a
successful generation.  Storing an empty string with advice_ok=True
made the page render "Thinking..." for ever, because the template's
`{% if reading.advice %}` was false - so the brewer never learned that
it had failed.

181 tests (up from 184 - the eight heuristic-era stripper tests are
replaced by six that test the marker behaviour).
2026-11-09 19:44:52 +10:00
hermes cbc71716e8 fix(advice): judge the working by its shape, not by its length
A length threshold is a blunt instrument: a legitimate advice reply can
run to a thousand characters, and a short working reply can be a few
hundred - so a threshold gets both wrong.  A 995-character advice reply
with no marker word in it was being emptied.

The reliable signal is the SHAPE.  The model's working is about
INTERPRETING the numbers - what the reading might mean, what could be
causing it, what it is unsure about.  The advice is about what to DO.

So a reply is working when it is full of the model's hedging about the
data AND contains no instruction at all.  Length is only a tie-breaker
for a reply that is both hedged and short.

    995-char legitimate advice  -> kept
    long pure working           -> dropped
    short legitimate advice     -> kept
    long working + advice       -> advice kept

Checked against the live model - a real reply comes back clean, with no
reasoning in it:

    At two weeks in, dropping from 1.055 to 1.020 is a healthy 35-point
    fall - about 64% attenuation - which is right in the normal range for
    a pale ale, though you likely still have 5 to 8 points to go...

184 tests (up from 183).
2026-11-09 19:27:08 +10:00
hermes 4bedd23c24 fix(advice): the no-token guard must cover all three shapes of "no token"
The guard matches on the absence of a token.  `{"$exists": False}` covers
a reading with no token field, but NOT one whose token field is present
and empty.

Verified against the live database, before the fix:

    token present but empty -> write matched: 0   <-- missed it

`{"$in": [None, ""]}` covers all three - a missing field, an empty
field, and a null one - because `$in` with null also matches a missing
field.

    no token field          -> write matched: 1
    token present but empty -> write matched: 1
    token is null           -> write matched: 1

183 tests (up from 182).
2026-11-09 19:10:16 +10:00
hermes d18d037bb9 fix(advice): the pre-token guard, and a stripper that cannot empty real advice
Two findings from the fifth review.

The pre-token fallback re-opened the hole
-----------------------------------------
A reading that has never been edited has no token.  The guard fell back
to the gravity check for those, and a comment-only correction leaves the
gravity unchanged - so a late thread's write matched and overwrote the
fresh advice, for exactly the 47 existing readings.

Reproduced live before the fix:

    pre-token reading token: None  ->  stale thread carries ""
    fresh write modified: 1
    stale write modified: 1   <<< stale advice overwrote the fresh one
    final advice: 'STALE ADVICE'

The guard now matches on the ABSENCE of a token, which still tells the
generations apart: an edit writes a token, so a stale thread's "" stops
matching the moment the reading is corrected.

    fresh write modified: 1 (must be 1)
    stale write modified: 0   (must be 0)

The stripper could empty real advice
------------------------------------
"Actually the drop is fine.\\n\\nWait a couple of days and check again."
is advice, and judging it paragraph by paragraph emptied it - so the
brewer saw nothing at all, with no failure note.

The test is no longer a marker list, which real advice often does not
contain.  A reply is all-working when it is LONG and has no instruction
in it: the model's working runs to thousands of characters, while the
advice is two to five sentences.

    short reply with no marker  -> kept
    long reply, no instruction  -> dropped
    long reply with advice      -> advice kept
    single-paragraph working    -> dropped

Also: the log message named the gravity, but the guard is the token.

182 tests (up from 178).
2026-11-09 19:08:28 +10:00
hermes 7d9d718b41 fix(advice): an empty token must not be used as a guard
None of the 47 existing readings have a token - they all predate it.  A
reading read from the database therefore has an EMPTY token, and the
guard was checking `expected_token is not None`, which is TRUE for an
empty string.  So the write was conditioned on a token that does not
exist, matched nothing, and **the advice would never have been written
for any of the existing readings**.

Verified against the live database, before the fix:

    write with expected_token='' -> 0   (0 = the advice would NEVER be written)

After:

    write for a pre-token live reading: 1   (must be 1)

An empty token is now not a usable guard - there is no token to match -
so the write falls through to the gravity check instead.  A non-empty
token is still the primary guard.

There is a test for it, with a reading inserted the way the existing
ones are - directly, with no token.

178 tests (up from 177).
2026-11-09 18:24:57 +10:00
hermes 27a28b5e58 fix(advice): the stripper must DROP the working, and the token is the guard
Two findings from the fourth review.

The stripper was a no-op in the case it was written for
---------------------------------------------------
The "all working" branch returned the text UNCHANGED, which is
byte-identical to the old fallthrough.  And in the case where a preamble
marker WAS present and no paragraph read like advice, it returned the
whole reply - working included - so the branch made the "store the
working instead of the advice" failure MORE likely, not less.

Measured on a realistic 5-paragraph reasoning reply: the old code stored
80 chars (one paragraph), the new code stored 612 chars (five
paragraphs) of the model's working.

It now DROPS the working, so the reply is empty and the brewer sees a
clear "could not be generated" rather than the model thinking about
their beer.

"keep" alone is not an advice marker
------------------------------------
"I need to keep in mind that the hydrometer was read at a different
temperature" is the model thinking, not advice.  The bare words "keep",
"raise", "lower", "take the" were matching ordinary reasoning phrasing,
so the "all working" detection almost never fired on real reasoning
text.  They are now matched as phrases - "you should keep", "raise it
to", "take another reading" - and "i need to keep in mind" is
explicitly working.

The token is the primary guard
------------------------------
The gravity can be unparseable for some readings, so `expected_sg` can
be None - and then the write was becoming unconditional, reintroducing
the stale-overwrite bug on those readings.

The editing token is the primary guard: it changes on EVERY edit, so it
catches a stale generation whatever else changed - the gravity, the
comment, or nothing.  The gravity is now a secondary check, used only
when there is no token.

177 tests (up from 174).
2026-11-09 18:22:51 +10:00
hermes a78aabe3ea fix(advice): an editing token, so a comment-only correction is safe too
The gravity guard caught a correction that changed the gravity, but a
COMMENT-ONLY correction leaves the gravity unchanged - so the late
thread for the old advice still matched and overwrote the fresh one.

Reproduced against the live database:

    fresh advice matched: 1
    stale thread matched: 1     <-- should be 0
    FINAL advice: 'STALE ADVICE'

Every edit now writes a fresh token onto the reading, and the advice
thread carries the token it was given.  A late thread finds a token that
no longer matches, so its write is refused.  The gravity is still
checked as well.

Verified against the live database:

    token changed: True
    fresh advice matched: 1
    stale thread matched: 0     <-- must be 0
    FINAL advice: 'FRESH ADVICE'

174 tests (up from 173).
2026-11-09 18:14:24 +10:00
hermes 8da3601941 fix(advice): the remaining review findings
Four things from the earlier review of this PR.

The prompt test did not test the prompt
---------------------------------------
`test_the_advice_is_about_the_reading_that_was_saved` asserted which
dict was PASSED to the generator - not what the model was actually ASKED.
So it passed even while the prompt still said "give advice for the most
recent reading", which is the bug that made the model advise about the
wrong number while the advice was filed against the right one.

It now asserts on the prompt content: the reading is marked, its date is
named, and "most recent reading" is gone.

Verified by reverting the prompt fix - the test fails, and so does
`test_the_prompt_names_the_reading_to_advise_about`.  Two tests now
guard it, and reverting the fix breaks both.

A write that matches nothing was silent
--------------------------------------
The advice writer discarded the return value of `set_reading_advice`.  A
write that matched 0 documents - which is now the EXPECTED outcome when
the brewer corrected the reading while the advice was being generated -
was dropped without a word.  It is logged now.

A stale docstring
-----------------
The background thread "builds its own" client - it has used the shared
one since the leak was fixed.

A missing CSS class
-------------------
`advice--reading` is used on the active-beers card and the run detail
page, and was never defined, so it had no styles.

173 tests (unchanged - the new assertions were added to existing tests).
2026-11-09 17:47:14 +10:00
hermes 3e1e4f42b7 fix(advice): stale advice must not overwrite a correction
The review found a race between the background advice thread and the
brewer correcting a reading.

`set_reading_advice` wrote unconditionally by id.  The advice is
generated in the background, so if the brewer corrects a reading while
the advice for the OLD gravity is still being generated, the slow thread
lands afterwards and overwrites the fresh advice with advice about a
reading that no longer exists.  The reading ends up with the final
gravity from the correction and the advice from before it.

Reproduced against the live database:

    wrote advice for 1050
    wrote advice for 1020
    late stale write matched: 0
    final sg: 1020 | final advice: 'ADVICE FOR THE CORRECTION'

The write is now conditional on the reading still holding the gravity
the advice was generated for, so a late thread matches nothing.

Also
----
`finish_reason` is now checked.  A reply that stopped at the token
ceiling is incomplete - it may be advice that stops mid-sentence - and
it was being stored as if it were the whole answer.  It is now marked
as cut off.

173 tests (up from 171).
2026-11-09 17:45:16 +10:00
hermes b414240bb5 fix(advice): keep the advice when the reply is over-long
The review found a way to store the model's WORKING instead of its
advice.

The advice is at the END of the reply, so the length cap was cutting
from the front - which took the advice off and left only the working.
That is worse than no advice at all, because it looks like advice: the
brew sees a full paragraph of the model thinking about their beer, and
nothing about what to do.

Reproduced:

    input len: 2515 -> final len: 1199
    advice present? False
    TAIL: 'padding padding padding padding padding'

Two changes:

- The length cap keeps the END of the reply and cuts at a sentence
  boundary near the end, so the advice survives.
- The stripper now also catches a reply that is entirely working - no
  marker phrase, and no paragraph that reads like advice - instead of
  only catching one that says "let me think".

    input: 2515 -> stripped: 2515 -> final: 1199
    advice present? True

A short reply is untouched.

171 tests (up from 169).
2026-11-09 17:36:13 +10:00
hermes cf08b751e4 feat(advice): use mistral-large-4, and cope with models that show their working
The model is now `mistral-large-4:cloud`, on the same Ollama instance.
It was probed before being made the default - `deepseek-v4-flash:cloud`,
`deepseek-v3.2:cloud` and `qwen3.5:cloud` are all retired and fail at
call time while still appearing in the model list.

What the probes showed
----------------------
`think: false` does NOT stop the reasoning.  It only changes where the
working goes: with thinking off, the model writes it into the CONTENT
instead of a separate field.  Either way the reasoning comes out of the
same token allowance as the answer, and at 500-3000 tokens the reply
came back EMPTY - which looks like a broken model rather than a budget
problem.  The budget is now 8000, and the timeout 120s.

The reply is also stripped of a reasoning preamble.  The advice is the
prose at the END; the working comes first, in several paragraphs.  The
preamble is found by walking back from the end of the reply and keeping
the paragraphs that read like advice to the brewer, rather than like
the model thinking out loud.

Verified against the live model, on a stalled brew (OG 1.042, 1.022 after
nine days, "no change since day 7"):

    That 1.022 reading after nine days at 19C - with no movement since
    day seven - looks like a stalled fermentation; a 1.042 wort should
    be well on its way to 1.010-1.012 by now, not sitting at only about
    48% attenuation. Gently swirl the fermenter to rouse the yeast cake
    and keep the temperature steady at 19-20C, and if the gravity hasn't
    budged in another 48 hours, pitch a fresh, healthy yeast starter...

And on a CORRECTED older reading - the case the review found broken -
the advice is about the corrected reading, with the later one as context:

    A brew-day reading of 1.038 against an OG of 1.042 is only four points
    low, which is almost certainly just the normal difference between
    your kettle gravity and the post-transfer fermenter reading...

169 tests (up from 167).
2026-11-09 17:10:24 +10:00
hermes 0011f30e59 fix(advice): a race in the shared repository, and the prompt naming the wrong reading
Two findings from the re-review of PR #14.

A race that recreated the leak
-------------------------------
`get_shared_repository()` did an unsynchronised check-then-set on a
module global.  Eight concurrent advice threads on a cold process each
saw None and each built their own repository - so eight MongoClients and
twenty-five threads.  The fix that was meant to remove the leak
reintroduced it, just bounded by a race instead of by the number of
readings.

Measured before: 8 repositories built, 25 threads.
Measured after:  1 repository built,  4 threads.

There is a lock, and the check is repeated inside it.

The prompt was about the wrong reading
--------------------------------------
The advice targeted the right reading, but the PROMPT said "give advice
for the most recent reading" - and when the brewer corrects an OLDER
reading, the most recent one in the history is a different reading.  So
the model was asked about the wrong number while the advice was filed
against the right one.

The reading is now named in the instruction (with its date and gravity),
marked in the reading list as the one to advise on, and the system prompt
says to advise about the marked reading because it may not be the most
recent one.

A corrected reading is also flagged as such, so the model knows it may
differ from what it said before.

167 tests (up from 164).
2026-11-09 17:04:57 +10:00
hermes c821a02d6e fix(charts): leave the suspect and active runs out of the bar charts (#15)
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(charts): leave the suspect and active runs out of the bar charts (#15)
2026-11-09 16:46:56 +10:00
hermes 794df0c12e fix(charts): leave the suspect and active runs out of the bar charts
The second review of PR #12 (after the suspect-run fix) found that the
two BAR charts were still plotting runs they should not have been, while
the curve chart and the tiles had been corrected.  The page was
contradicting itself.

The ABV chart plotted the suspect run
--------------------------------------
Run 12's impossible reading gives it an ABV of 18.64%.  The chart's y
axis went to 18.64, so the fifteen real bars were squashed into the
bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same
page showed the correct figure.  The chart now leaves the suspect runs
out.

The attenuation chart plotted the active runs
----------------------------------------------
An active run's attenuation is measured against its CURRENT gravity, so
a brew that has just started shows a 0% bar.  That is the absence of a
result, not a result, and it sat directly above the table of finished
brews.  The chart now leaves the active runs out.

Verified against the live data:

    ABV chart bars:        bars=15 max=8.53   (was 16 bars, max 18.64)
    attenuation chart bars: bars=15 min=76.2   (was 17 bars, min 0)

A suspect active run no longer vanishes
---------------------------------------
The API flags suspect runs over EVERY run, so the charts leave them out.
But the analysis page only looked at the finished ones, so an active run
with a bad reading would be dropped from the charts and never listed in
"Readings that need a second look" - it would disappear with no
explanation anywhere.  The analysis page now lists suspect active runs
too.

The figures say what they are based on
--------------------------------------
- The analysis prose counted the non-suspect runs, not the ones that
  actually contributed a figure to the mean.  It now counts the latter.
- The history page's "Average ABV — across finished brews" was averaging
  15 of 16 without saying so, while the analysis page said "16 brews (15
  counted)".  It now says "across 15 of 16 finished brews".
- `/api/runs/<run_id>` carries the `suspect` and `active` flags that
  `/api/runs` does.

148 tests (up from 142).
2026-11-09 16:46:30 +10:00
hermes 21fa0d9a12 fix(advice): make the id helper unusable rather than merely rejected
_as_doc_id returned None for a hostile id.  That was guarded at the one
call site, but the helper could still be misused into "$in needs an
array" - and a caller who forgot the guard would have got an error
rather than a safe no-op.

It now returns an EMPTY LIST for anything that is not a reading id.  An
empty list is a valid $in argument that matches nothing, so the helper
cannot produce a query error, cannot match every document, and cannot be
interpreted as a query operator.

Verified against the live database: a hostile id matches 0 documents and
updates 0 documents.

164 tests.
2026-11-09 16:31:05 +10:00
hermes 6dd0b7b046 fix(advice): the review findings - the ids, the client leak, the wrong reading
The review of PR #14 found a critical defect and two real bugs.  All
verified against the live database, not just read.

CRITICAL - the advice would never have been stored
--------------------------------------------------
`_as_doc_id` accepted only an ObjectId or a string that parses as one.
But **all 47 live readings have string `_id`s** - 24 random characters
from the original app's importer, like `l78ZidBJ3QaYn98tvB88x4uM`.  The
helper turned every one of them into None, so the update matched nothing
and the advice was silently never stored in production.

The mongomock fixture generates ObjectIds, so the tests could not see it.
`_as_doc_id` now returns both forms and the query uses `$in` (a plain
list would be an exact ARRAY match in Mongo, which is not what is
wanted).  Verified against the live database: the advice now lands on a
real reading, with the brewer's comment untouched.

There are now tests for BOTH id shapes, with the string shape built
explicitly, because the fixture would not produce it.

The advice was written about the wrong reading
----------------------------------------------
`latest` was taken from the date-sorted tail of the run, so editing an
OLDER reading generated advice about the NEWEST one.  The advice is now
written against the reading that was actually saved.

A leaked MongoClient per reading
--------------------------------
The background thread built a `BeerRepository()` - and therefore a
`MongoClient` - for every advice write, and never closed it.  A
MongoClient spawns three monitor threads and pymongo does NOT reap them
on `close()`; they go only when the client is garbage collected.

Measured: three threads and a connection pool accumulated per reading
recorded.  There is now one shared repository for the life of the
process, which is what the request-scoped repository does too.  Measured
again after the fix: 4 threads total, not 16 over five writes.

Also
----
- `BeerRepository` gained `close()` and the context-manager protocol.
- The advice writer now logs instead of dying silently if the write
  fails.

164 tests (up from 160).
2026-11-09 16:29:39 +10:00
hermes d0aef7a787 fix(advice): clear stale advice on edit, and pin the injection guard
Two things I wanted to be sure of before this merges.

Stale advice
------------
The advice is written for the gravity that was there when it was
generated.  If the brewer then edits the reading - to fix a typo, or to
mark it final - the old advice no longer describes it, and the page was
showing advice that contradicted the number next to it.

`upsert_reading` now clears the advice fields, so the stale advice goes
and a fresh one is generated (the form already regenerates on every save).
Only the advice fields are cleared - the brewer's own comment is never
touched.

The injection guard
-------------------
The reading's `_id` is an ObjectId, so the original `_as_id` coercion
(which turns run ids into plain strings, to stop a caller passing a dict
that Mongo would interpret as a query operator) did not apply - it turned
the ObjectId into an empty string and the update silently matched
nothing.  `_as_doc_id` accepts only an ObjectId or a string that parses as
one.

Verified against a live injection attempt: `{"$ne": None}`, `{"$gt": ""}`,
a list, an int, None and a non-ObjectId string all return None and match
nothing.  There is now a test that pins it, because it is a security
property that would otherwise only be covered by reading the code.

160 tests (up from 158).
2026-11-09 16:05:59 +10:00
hermes ab3050194d feat(advice): LLM brewing advice on every observation (work in progress)
Stores a model's advice for each recorded reading, in its own field, so the
brewer's own comment is never overwritten.

Fire and forget: the advice is generated in a background thread, so
recording a reading never blocks on the model.  A failure is recorded as a
visible note saying the advice could not be generated, because a silent
failure is indistinguishable from "still thinking".

The model is `glm-5.3:cloud` on the Ollama instance at 192.168.178.45:11434.
Several models on that instance are retired and fail at call time while
still appearing in the model list, so the default is one that was probed.

The system prompt tells the model never to call a reading impossible: a
gravity below 1.000 is always a mis-entry, but the rest of the run may be a
genuine long fermentation and still worth understanding.

150 tests.
2026-11-09 16:03:29 +10:00
hermes 41cf2cbb8e fix(analysis): rising gravity, undated readings, and the range band (#13)
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(analysis): rising gravity, undated readings, and the range band (#13)
2026-11-09 15:57:59 +10:00
hermes 76a4d0805c fix(analysis): the review findings - rising gravity, undated readings, the band
A second, deeper review of PR #12 found five things the first review did
not.  All are fixed here.

The band was not the band
-------------------------
The fermentation curve's shaded range was drawn as ONE dataset with
`fill: "+1"`, which fills to the NEXT dataset - and the next dataset was
the median line, not the maximum.  So the band shaded min-to-median, the
upper half of the range was drawn as a hairline but never shaded, and the
tooltip said "range min to max" while the picture showed something else.
The band is now two datasets - the min curve filled down to a max curve -
with the max hidden from the legend.

Rising gravity
--------------
`attenuation_percent` rejected a final gravity below 1.000 and one
implying more than 100% attenuation, but a run whose gravity went UP
produced a NEGATIVE attenuation - which was then averaged into the
headline figure and plotted as a negative bar.  Verified against the live
code: OG 1010 -> FG 1020 gave -100.0%.  Now rejected, and reported by
`implausible_runs` with the reason "the gravity went up, which is usually
a mis-ordered pair of readings".

Undated readings
----------------
An undated reading sorted to the front of the run, so it became the
original gravity - which put the OG after the FG and produced a negative
attenuation.  The OG now comes from the earliest *dated* reading, and
`days_to_target` skips the readings it cannot place on the timeline rather
than giving up on the whole run.  Verified: readings 1050 (dated), 1005
(undated), 1010 (dated) gave None instead of 24 days, and an OG of 1005
instead of 1050.

Active runs on the curve
------------------------
The curve chart filtered on `curve.length > 1` only, so it plotted the
active runs as well - a partial fermentation on a chart meant to describe
how a completed one behaves.  It now excludes them, along with the
suspect runs.

Four fetches, one payload
-------------------------
`/api/runs` carries every reading of every run, and four charts each
fetched it independently - so the analysis page made two identical
round-trips of the whole collection.  There is now one shared fetch.

Also
----
- The "Readings that need a second look" table links to the run, so a
  suspect reading can be corrected in one click.
- `days_to_1015` and the `curves` variable were computed and never used.
  Removed.
- A reading below 1.000 now says what it is - "beer cannot be less dense
  than water, so this is almost certainly a typo (a 1.012 reading entered
  as 012?)".  The reading is still SAVED: rejecting it would lose the
  brewer's actual observation, and seeing it recorded is how they spot the
  mistake.

142 tests (up from 134).
2026-11-09 15:55:56 +10:00
hermes 21e7553ab4 feat(analysis): attenuation, fermentation curves and brew timings (#12)
Build and Push Image / Build and push image (push) Successful in 8m10s
feat(analysis): attenuation, fermentation curves and brew timings (#12)
2026-11-09 14:18:44 +10:00
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
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
hermes 4d1e070d74 docs: document the analysis page and the CI deploy flow
The RUNBOOK still said the workflow did not deploy to the cluster. It does
now - it builds, pushes, applies the manifests, rolls the pods and verifies
the app from inside the pod. The manual deploy path is documented alongside
it, along with the dry-run step that catches the schema errors kubectl apply
silently swallows.
2026-11-09 13:12:51 +10:00
hermes e1a9a7384f feat(analysis): attenuation, fermentation curves and brew timings
Adds the analysis page the old app never had: how consistently the brews
ferment, and how long each one takes.

What it shows
-------------
- **Attenuation by brew** - what fraction of the original gravity was
  fermented away, for every finished brew.
- **Fermentation curves** - the typical shape of a fermentation, drawn as a
  median curve with the min-max envelope behind it. Plotting every brew
  individually gave 16 near-identical lines and an unreadable legend.
- **Every finished brew** - OG, FG, ABV, attenuation, days in the fermenter,
  days to reach 1.010, and the finish date.
- **Readings that need a second look** - readings outside the plausible
  hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
  average time to 1.010.

Data quality
------------
One live run (run 12) has a final gravity of 900, which worked out as 338%
attenuation and dragged the average to 103.2%. That is not a real result,
so:

- Attenuation is rejected when the final gravity is not physically possible
  for the original gravity.
- Runs that fail that check are EXCLUDED from the averages rather than
  silently distorting them, and are listed on the page as needing review.
- The average now reads 87.5% +/- 7.4 percentage points across 16 brews.

Chart bugs found while building this
------------------------------------
- **The gravity charts had their y axis reversed.** With `reverse: true`
  the high reading sits at the BOTTOM, so the fermentation curve climbed
  left to right - it read as gravity *rising* as the beer fermented. A
  brewer reads a fermentation as a descending line. The point-scale run
  chart on the run detail page had the same bug. Both are fixed.
- **The curve chart's x axis was treated as categorical.** The x values are
  numeric (0-100), and Chart.js defaults a line chart's x axis to
  category, which collapsed every point onto index 0 and rendered the
  curve as a sliver at the origin. The axis is now explicitly linear.
- **The curve was interpolated by exact key lookup**, so it only worked when
  every brew happened to be read at the same progress points. It now
  interpolates between a brew's actual readings, and the curve carries 21
  points across the full range.
- **Long beer names were clipped** at the left edge of the attenuation
  chart ("...x crying out loud"). Names are trimmed to 18 characters with
  an ellipsis; the tooltip and the table carry the full name.

Tests
-----
127 (up from 105). The two chart regressions are covered by reading
`charts.js` and asserting the gravity axis is not reversed and that long
names are trimmed - these are exactly the kind of change that silently
reverts, and they are invisible to a screenshot test.
2026-11-09 13:11:23 +10:00
hermes 0785c11790 fix(ci): use the house CI pattern instead of a bespoke one (#11)
Build and Push Image / Build and push image (push) Successful in 4m27s
fix(ci): use the house CI pattern instead of a bespoke one (#11)
2026-11-09 12:50:21 +10:00
hermes 563e22fb00 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:49:54 +10:00
hermes bb071343c2 fix(ci): count the kubeconfig's clusters correctly
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:49:13 +10:00
hermes 8048a1112d docs: fix the PR #5 merge commit message (#10)
Build and Push Image / Unit tests (push) Successful in 50s
Build and Push Image / Build and push image (push) Successful in 58s
Build and Push Image / Deploy to the cluster (push) Failing after 15s
docs: fix the PR #5 merge commit message (#10)
2026-11-09 12:39:03 +10:00
hermes 9f2671fa71 feat(active): add the active beers page, run metadata and tap numbers (#5)
Build and Push Image / Unit tests (push) Successful in 1m55s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment.

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 or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter - its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished brews.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.

Tests
-----
105 (up from 74). New coverage: the active page contents, the tap number,
the projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next
free tap number, the partial-update semantics, the history page, the
outlier list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts
into `beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:38:27 +10:00
hermes 060269b03d x
Build and Push Image / Unit tests (push) Successful in 1m7s
Build and Push Image / Build and push image (push) Successful in 1m47s
Build and Push Image / Deploy to the cluster (push) Failing after 34s
x
2026-11-09 12:37:19 +10:00
hermes 2a9e0ad15e fix: log index failures, and tidy the CSP and the API error response
Build and Push Image / Unit tests (push) Successful in 1m6s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Round-2 review findings.

- `_ensure_indexes` swallowed a `PyMongoError` with a bare `pass`. An index
  failure does not stop the app serving, but it means the lookup is a
  collection scan, which gets slow as the data grows — so it is now logged
  as a warning with the traceback.

- The Content-Security-Policy string is now a module-level `CSP` constant
  rather than inline in the response handler, and it gained
  `object-src 'none'` (the app uses no plugins). The test now also asserts
  that neither `unsafe-inline` nor `unsafe-eval` appears — either would
  defeat the point of the policy.

- `api_run` returned a bare dict for its 404 while the success path used
  `jsonify`. It is now `jsonify({"error": "not found"})` for consistency.
2026-11-09 12:36:17 +10:00
hermes bf0f88dd70 fix: build the image on Python 3.14, and fix the review findings
The image build failed on master:

    ERROR: Ignored the following versions that require a different python
    version: 4.19.0 Requires-Python >=3.11
    ERROR: Could not find a version that satisfies the requirement
    pymongo==4.19.0

The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.

All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.

The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.

Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
  the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
  a strict `default-src 'self'` policy is enforceable. Adding it required
  removing the inline `style=` attributes from the templates (they became
  `.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
  id now travels as a `data-run-id` attribute). A test asserts no template
  has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
  a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
  (a 1.150 wort is real) can be recorded.

Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter — its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
  code behind. It is restored.

Tests: 105 (up from 91).
2026-11-09 12:36:17 +10:00
hermes 1fa9f1dcbe feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and 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 or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.

Tests
-----
91 (up from 74). New coverage: the active page contents, the tap number, the
projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next free
tap number, the partial-update semantics, the history page, the outlier
list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts into
`beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:35:50 +10:00
hermes 081014b8dc fix(ci): self-heal a stale cluster CA in the deploy job (#9)
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Successful in 1m3s
Build and Push Image / Deploy to the cluster (push) Failing after 21s
fix(ci): self-heal a stale cluster CA in the deploy job (#9)
2026-11-09 12:16:00 +10:00
hermes 3e20b558a5 fix(ci): harden the CA refresh script and add shellcheck to CI
Build and Push Image / Unit tests (push) Successful in 48s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Round-2 review findings, all low severity.

- `curl` now has `--connect-timeout` and `--max-time`, so an unreachable API
  server fails the job instead of hanging the runner indefinitely.

- A guard for a kubeconfig with no cluster entries, or an empty one, with a
  clear message rather than an opaque kubectl error.

- The fingerprint mismatch message now prints both the expected and the
  actual value, which cuts the time to diagnose the next CA rotation.

- `set -euo pipefail` was already there; the script now passes shellcheck
  clean, and a shellcheck step was added to the CI test job so the
  operational scripts get the same treatment as the Python code.
2026-11-09 12:15:50 +10:00
hermes a4b3d40e2d fix(ci): pin the cluster CA in the repo rather than in a secret
Build and Push Image / Unit tests (push) Successful in 43s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The CA fingerprint is not a secret.  It is a public value that anyone can
read straight off the certificate, so pinning it in a Gitea secret was the
wrong shape:

- A change to it would be invisible - no commit, no review.
- Nobody with secret-write access could silently repoint the CI runner at a
  different CA.

It now lives in `scripts/cluster-ca.sha256`, so changing it is a reviewable
commit.  The script reads it from there, and still accepts an override as
its first argument.

This also removes the need for a `KUBE_SERVER_CA_FINGERPRINT` secret, which
the bot token cannot create anyway - the Actions secrets API is owner-only.

The behaviour is unchanged: the CA is fetched over an unverified connection
but only USED if it matches the pinned fingerprint, so an intercepted CA is
refused.  Re-tested against the live API server - the matching fingerprint
refreshes the CA and kubectl then works; a wrong one is refused and the
stale CA is left in place.
2026-11-09 12:13:27 +10:00
hermes ce6f5b7962 fix(ci): pin the cluster CA fingerprint instead of trusting a fetched CA
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The review was right on both counts, and both are fixed.

1. MITM. Fetching the CA over an unverified connection (`curl -sk`) and then
   trusting it is a privilege-escalation hole: the runner holds cluster-admin
   credentials, so an attacker able to intercept the fetch could hand us a CA
   of their choosing and then read everything sent over the connection.

   The CA is now PINNED. It is still fetched over an insecure connection, but
   it is only USED if its SHA-256 fingerprint matches
   `KUBE_SERVER_CA_FINGERPRINT`. An intercepted CA will not hash to the
   pinned value, so it is refused. If the secret is not set, the job fails
   rather than trusting an unverified CA.

2. The hardcoded cluster name. `kubectl config set-cluster default` creates a
   NEW cluster entry when the kubeconfig's entry is called something else,
   leaving the original (and its stale CA) in use. The name is now read from
   the kubeconfig: `{.clusters[0].name}`.

The bash is also moved out of the YAML into `scripts/kube-ca-refresh.sh`, so
it can be read, reviewed and tested on its own rather than as a wall of
shell inside a workflow file.

All three paths are tested against the live API server:

- no fingerprint supplied   -> refuses, exit 1
- wrong fingerprint        -> refuses, exit 1
- correct fingerprint      -> refreshes the CA, exit 0, kubectl then works
2026-11-09 12:11:56 +10:00
hermes 687339bb9e fix(ci): self-heal a stale cluster CA in the deploy job
Build and Push Image / Unit tests (push) Successful in 55s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The deploy job failed on its first run:

    error: error validating "kube/beer_namespace.yaml": error validating
    data: failed to download openapi: Get "https://192.168.178.160:6443/openapi/v2":
    tls: failed to verify certificate: x509: certificate signed by unknown authority

This is the same class of problem as the buildx driver earlier: the
certificate-authority-data embedded in the kubeconfig secret does not match
the CA the API server is currently presenting.

Rather than requiring the secret to be regenerated by hand every time the
cluster's CA is rotated, the job now refreshes it:

- The API server publishes its own CA at `/cacerts`.
- If kubectl cannot verify the server, the job fetches that CA (over an
  insecure connection — verifying it is the problem being solved) and pins
  it explicitly with `kubectl config set-cluster`.
- It then retries, and fails with a clear message if that still does not
  work.

Verified by deliberately corrupting the CA in a working kubeconfig: the
failure reproduces exactly, and the recovery path restores connectivity
(`Server Version: v1.34.5+k3s1`).

The secret is also switched from `KUBEC_CONFIG_BUILDX` to
`KUBEC_CONFIG_BUILDX_NEW_2`, which is the one the working `wedding-photos`
workflow uses.
2026-11-09 12:08:49 +10:00
hermes c28553a1b8 feat(ci): add the deploy job and the kubernetes manifests (#8)
Build and Push Image / Unit tests (push) Successful in 43s
Build and Push Image / Build and push image (push) Successful in 1m22s
Build and Push Image / Deploy to the cluster (push) Failing after 1m29s
feat(ci): add the deploy job and the kubernetes manifests (#8)
2026-11-09 12:00:35 +10:00
hermes 958ae9b365 fix(ci): keep the credentials out of the process list
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Two small changes to the deploy job:

- The registry password went through `-u armistace:$PASSWORD` on the curl
  command line, where it is visible in the process list for the duration of
  the request. It now goes through a curl config file on stdin instead.

- The kubeconfig is written with mode 600 rather than the default, and is
  removed at the end of the job (with `if: always()`, so it is removed even
  if a later step fails). It is cluster-admin-equivalent, so it should not
  be readable by anything else on the runner.

Verified: `curl --config -` against the registry returns the same digest as
the `-u` form.
2026-11-09 12:00:18 +10:00
hermes 55d5e84150 fix(ci): pin the image to the commit SHA instead of the mutable :latest tag
Build and Push Image / Unit tests (push) Successful in 41s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The review flagged `:latest` as a critical issue, and it is right: a mutable
tag makes `kubectl rollout undo` meaningless and lets different pods in the
same deployment run different code if they restart at different times.

- The build job now pushes the image with BOTH `:latest` and
  `:${{ github.sha }}`.
- The deployment manifest carries a sentinel tag rather than `:latest`, and
  the deploy job sets the real SHA with `kubectl set image`. That makes the
  deploy a genuine manifest change, so the Deployment rolls because its pod
  template changed — not because it was told to. `kubectl rollout undo` now
  means something: it goes back to the previous SHA.
- The digest check now compares against the SHA tag rather than `:latest`,
  and it checks EVERY running pod instead of `items[0]`. During a rolling
  update `items[0]` may still be an old pod that has not been terminated,
  which would compare against the wrong digest. The job also fails if the
  deployment is not fully rolled out.
- The smoke test now checks the app through the Service as well as directly
  in the pod. The pod check alone would pass even if the Service selector
  or the port mapping were wrong.
- `readOnlyRootFilesystem: true` — the app writes no files, so the root
  filesystem can be read-only. /tmp is still writable via the emptyDir.
- The kubectl install is skipped when the runner already has one.
2026-11-09 11:59:22 +10:00
hermes b73df93f20 feat(ci): add the deploy job so the app deploys from the repo
Build and Push Image / Unit tests (push) Successful in 1m8s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The workflow built and pushed the image but never deployed it - the
cluster objects had been created by hand. This adds a `deploy` job that
applies the manifests in `kube/` and rolls the deployment.

The deploy job is separate from the build job on purpose. The
`KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
that never talks to the cluster should not be able to read it.

Three things this job does that a naive `kubectl apply` does not:

1. `kubectl rollout restart`. The image tag is `:latest`, which is immutable
   in the manifest, so `apply` alone does not roll the pods. Without the
   restart the deployment reports success while the old pods keep serving
   the old image - a deploy that silently changes nothing.

2. Verifies the running digest against the registry's `:latest`. CI green and
   pods `Ready` do not prove the new code is live; the digest comparison does.

3. Smoke tests the running app - `/healthz` and `/readyz` - from inside the
   pod, so a deploy that comes up but cannot serve is caught in CI rather
   than by the next person to open the page.

The Secrets (`beer-db`, `beer-app`) are hand-provisioned and are deliberately
not in the apply set, so a deploy cannot clobber them.
2026-11-09 11:46:06 +10:00
hermes 55c5de9d61 feat(deploy): add the kubernetes manifests so the deployment is in the repo
The cluster objects for the beer namespace were created by hand. This puts
them in the repo so they can be applied and reviewed like everything else.

- `beer-deployment.yaml` — the Deployment, with the security context, the
  resource limits, and both probes wired up (`/healthz` liveness,
  `/readyz` readiness).
- `beer-service.yaml` — the NodePort service.
- `beer_namespace.yaml` — the namespace.

The credentials are NOT in these files. `MONGO_PASS` and `SECRET_KEY` come
from the live `beer-db` and `beer-app` Secrets, so applying the manifests
cannot clobber a hand-provisioned credential.

Notes on the pod security context
---------------------------------
`runAsNonRoot: true` cannot be set on the container when the image's user is
a *named* user - kubelet can only verify a numeric UID. The pod-level
security context therefore sets `runAsUser: 999`, which matches the `beer`
user the image creates (verified with `id` in the built image).

The container also drops all capabilities and refuses privilege escalation.
2026-11-09 11:45:41 +10:00
31 changed files with 6406 additions and 274 deletions
+62 -64
View File
@@ -1,68 +1,35 @@
name: Build and Push Image
on: [push]
on:
push:
branches:
- master
jobs:
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# NOTE: no actions/setup-python here. That action downloads a Python
# build from GitHub, which this runner cannot reach reliably - the job
# hung on it. The runner image already carries a usable python3, so
# the tests use it directly.
- name: Show the Python version
run: python3 --version
- name: Install dependencies
run: |
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run tests
run: |
. .venv/bin/activate
python -m pytest tests/ -q
# NOTE: the build job deliberately does NOT have a kubeconfig. The
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
# that has no need of the cluster should not be able to read it. The deploy
# step, added in a later PR, gets its own job with the kubeconfig - that way
# a compromised dependency in the build cannot reach the cluster.
build:
name: Build and push image
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
needs: test
if: gitea.ref == 'refs/heads/master'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
# The kubeconfig is written FIRST, because the buildx `kubernetes`
# driver needs it to create its builder pod in the cluster. This is
# the same ordering as the working workflows in resbuilder_ai and
# wedding-photos.
- name: Create Kubeconfig
run: |
mkdir $HOME/.kube
echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The `kubernetes` driver talks to the cluster API, which needs the
# k3s CA certificate. The job container does not have it, so the
# bootstrap failed with "x509: certificate signed by unknown
# authority".
#
# The `remote` driver talks straight to the buildkit daemon that is
# already running in the cluster, so no cluster API access (and no
# kubeconfig) is needed at all. This is the same pattern the
# wedding-photos workflow uses successfully.
#
# NOTE: `endpoint` must be passed as a top-level key. The action
# appends it as the POSITIONAL argument to
# `buildx create --driver remote <endpoint>`; putting it in
# driver-opts instead yields "ERROR: no remote endpoint provided".
driver: remote
endpoint: tcp://buildkit.gitea-runner.svc:1234
driver: kubernetes
driver-opts: |
namespace=gitea-runner
qemu.install=true
- name: Login to Docker Registry
uses: docker/login-action@v3
@@ -71,25 +38,56 @@ jobs:
username: armistace
password: ${{ secrets.REG_PASSWORD }}
- name: Build Base Image
uses: docker/build-push-action@v5
with:
context: .
push: true
file: base.Dockerfile
# amd64 only. A multi-arch build runs qemu emulation, which crashes
# the buildkit pod on this NUC (exit 255, SandboxChanged) and the
# build then hangs for many minutes. The cluster is amd64.
platforms: linux/amd64
tags: |
git.aridgwayweb.com/armistace/beer_base_image:latest
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
file: flask.Dockerfile
# amd64 only. A multi-arch build runs qemu emulation, which
# crashes the buildkit pod on this NUC (exit 255, SandboxChanged)
# and the build then hangs for many minutes. The cluster is
# amd64, so arm64 images are never used.
platforms: linux/amd64
tags: |
git.aridgwayweb.com/armistace/beer-data:latest
- name: Deploy
run: |
echo "Installing Kubectl"
apt-get update
apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
chmod 644 /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install kubectl
# Do NOT delete the namespace - that would destroy the live
# Secrets (beer-db, beer-app), which are provisioned by hand.
# Apply the namespace (idempotent) and the manifests.
kubectl apply -f kube/beer_namespace.yaml
# The image-pull credentials. Created idempotently so a deploy
# does not fail when they already exist.
kubectl create secret docker-registry beercred --docker-server=git.aridgwayweb.com --docker-username=armistace --docker-password='${{ secrets.REG_PASSWORD }}' --docker-email=armistace@aridgwayweb.com --namespace=beer --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f kube/beer-deployment.yaml
kubectl apply -f kube/beer-service.yaml
# `kubectl apply` does NOT roll the pods when only the image
# changed, because the image tag in the manifest is unchanged.
# Without the restart the deployment reports success while the
# old pods keep serving the old image.
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
- name: Verify the deployed app
run: |
# Pods Running is not proof the app works. Exercise the real
# endpoints from inside the pod.
kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/healthz'); print('healthz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/readyz'); print('readyz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
+75 -2
View File
@@ -34,6 +34,78 @@ python -m pytest tests/ -q
The tests use `mongomock`, so they need no database and no network.
## The pages
| Page | What it is for |
|---|---|
| `/login` | Sign in |
| `/brews` | The brew process: record a reading, review every run |
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
| `/analysis` | How consistently the brews ferment — attenuation, fermentation curves, brew timings |
### `/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.
### `/analysis` — how the brews ferment
The page that turns the history into something you can brew better from:
- **Attenuation by brew** — what fraction of the original gravity was
fermented away, for every finished brew.
- **Fermentation curves** — the typical shape of a fermentation, drawn as a
median curve with the min–max envelope behind it. Plotting every brew
individually gives sixteen near-identical lines and an unreadable legend.
- **Every finished brew** — OG, FG, ABV, attenuation, days in the fermenter,
days to reach 1.010, and the finish date.
- **Readings that need a second look** — readings outside the plausible
hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
average time to 1.010.
A reading that is physically impossible — a final gravity below the original
gravity by more than the wort itself could have — is excluded from the
averages rather than silently distorting them, and the run is listed on the
page so it can be corrected.
### Run metadata
Some details cannot be derived from the readings, so they are stored per run in
the `beer_runs` collection and edited on the run detail page:
- the **canonical name** the brew is known by (overriding the drifting
per-reading name),
- the **tap number**,
- the **original gravity**,
- free-text **brew notes**.
### Populating the run metadata from the existing readings
```bash
# dry run - prints what it would do, writes nothing
python scripts/backfill_run_metadata.py
# write it
python scripts/backfill_run_metadata.py --apply
```
The script is idempotent and non-destructive: it only inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand (pass `--force` if you want it to).
## Layout
```
@@ -41,11 +113,12 @@ src/flask/
beer_data.py entry point; exposes `app` and `create_app`
app_factory.py application factory, request-scoped Mongo repository
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
views/ blueprints: auth, brews, (later) analysis
views/ blueprints: auth, brews, analysis
mongo/repository.py Mongo access for the beer_data and beer_runs collections
templates/ Jinja templates
static/ CSS, JS, fonts, logo (all served locally — no CDN)
static/ CSS, JS, fonts, logo (all served locally - no CDN)
tests/ pytest suite
scripts/ operational scripts (the run-metadata backfill)
```
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
+65 -3
View File
@@ -69,15 +69,53 @@ env:
## Deploying
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the
test suite and then builds and pushes the image. **The workflow does not
currently deploy to the cluster** — apply the manifests yourself:
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which builds
the image, pushes it, **deploys it to the cluster and verifies it**. There is
no manual step.
The workflow is a single job, in this order:
1. **Checkout**
2. **Create Kubeconfig** — writes `secrets.KUBEC_CONFIG_BUILDX_NEW_2` to
`$HOME/.kube/config`. This is FIRST because the buildx `kubernetes` driver
needs it to create its builder pod in the cluster.
3. **Set up Docker Buildx** — `driver: kubernetes`, `namespace=gitea-runner`.
4. **Login to the registry** — `secrets.REG_PASSWORD`.
5. **Build and Push** — `linux/amd64` only. A multi-arch build runs qemu,
which crashes the buildkit pod on this NUC (exit 255, `SandboxChanged`)
and the build then hangs for many minutes.
6. **Deploy** — installs kubectl, applies `kube/*.yaml`, creates the
image-pull secret idempotently, then `rollout restart` + `rollout status`.
The rollout restart is required: the image tag in the manifest is
`:latest`, so `kubectl apply` alone does not roll the pods.
7. **Verify the deployed app** — exercises `/healthz` and `/readyz` from
inside the pod, using the pod's own python (there is no `curl` in the
slim image).
> The deploy step does **not** delete the namespace. That would destroy the
> hand-provisioned `beer-db` and `beer-app` Secrets.
### Deploying by hand
If you need to deploy without going through CI — for example to test a
manifest change — do the same thing the workflow does:
```bash
kubectl apply -f kube/beer_namespace.yaml
kubectl apply -f kube/beer-deployment.yaml
kubectl apply -f kube/beer-service.yaml
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
```
Validate the manifests before applying them. `kubectl apply` **silently
rejects** a manifest with a schema error, so a broken manifest leaves the
old Deployment in place while the job still reports success:
```bash
kubectl apply --dry-run=client -f kube/
```
### Verify the deploy actually took
CI green and pods `Ready` do **not** prove the new code is running. Check the
@@ -130,6 +168,30 @@ livenessProbe:
periodSeconds: 20
```
## The run metadata collection
The `beer_runs` collection holds one document per brew run with the details
that cannot be derived from the readings: the canonical name, the tap number,
the recorded original gravity and the brew notes.
It starts empty on a fresh install. Populate it from the existing readings:
```bash
# from the app's source tree, with the usual env vars set
python scripts/backfill_run_metadata.py # dry run
python scripts/backfill_run_metadata.py --apply # write it
```
The script is idempotent and only ever inserts into `beer_runs`. It does not
touch `beer_data`.
To see what is in it:
```bash
mongosh "mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
--eval 'db.beer_runs.find().pretty()'
```
## Data safety
The `beer_db.beer_data` collection holds the actual brewing records. There is
+90
View File
@@ -0,0 +1,90 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: beer-deployment
namespace: beer
labels:
app: beer-data
spec:
replicas: 3
selector:
matchLabels:
app: beer-data
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: beer-data
spec:
securityContext:
# Pod-level: only runAsNonRoot/runAsUser/runAsGroup/fsGroup belong
# here. allowPrivilegeEscalation and capabilities are container-level.
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
containers:
- name: beer-data
image: git.aridgwayweb.com/armistace/beer-data:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
protocol: TCP
env:
- name: MONGO_HOST
value: "192.168.178.229"
- name: MONGO_USER
value: "root"
# MONGO_PASS and SECRET_KEY come from the live Secret - they are
# deliberately NOT in this file. See RUNBOOK.md.
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: beer-db
key: mongo_pass
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: beer-app
key: secret_key
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 50m
memory: 64Mi
securityContext:
# Container-level: these two belong here, not on the pod.
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
nodeSelector:
kubernetes.io/hostname: archlinux-k3s-3
imagePullSecrets:
- name: beercred
terminationGracePeriodSeconds: 30
restartPolicy: Always
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: beer-service
namespace: beer
labels:
app: beer-data
spec:
type: NodePort
selector:
app: beer-data
ports:
- name: http
port: 80
targetPort: 5000
nodePort: 30015
protocol: TCP
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: beer
labels:
app: beer-data
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Backfill the ``beer_runs`` metadata collection from the existing readings.
The ``beer_data`` collection stores a beer name on *each reading*, and that
name drifts across a run — run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". The
``beer_runs`` collection holds one canonical name per run, which is what the
active-beers page and the analysis pages use.
This script populates that collection from the readings. It is **idempotent**
and **non-destructive**:
- It only ever inserts into ``beer_runs``. It never touches ``beer_data``.
- It never overwrites a name you have already set by hand. A run whose
metadata already carries a ``name`` is left alone unless ``--force`` is
given.
- Re-running it produces the same result.
Usage
-----
python scripts/backfill_run_metadata.py # dry run
python scripts/backfill_run_metadata.py --apply # write it
python scripts/backfill_run_metadata.py --apply --force # overwrite names
The connection is taken from the same environment variables the app uses:
``MONGO_HOST``, ``MONGO_USER``, ``MONGO_PASS`` (or ``MONGO_URI``).
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "flask"),
)
from brewing import Run # noqa: E402
from mongo.repository import BeerRepository # noqa: E402
def build_plan(repo: BeerRepository) -> list[dict]:
"""Work out what should be in ``beer_runs``, one entry per run."""
existing = repo.all_run_metadata()
plan = []
for run in repo.runs():
metadata = existing.get(run.beer_run_id, {})
canonical = metadata.get("name") or run.display_name()
og = metadata.get("og_points") or run.original_gravity()
plan.append({
"beer_run_id": run.beer_run_id,
"name": canonical,
"og_points": og,
"tap_number": metadata.get("tap_number"),
"notes": metadata.get("notes", ""),
"readings": len(run.readings),
"active": run.is_active(),
"existing_name": metadata.get("name"),
"would_change": not metadata.get("name"),
})
return plan
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true",
help="write the changes (default is a dry run)")
parser.add_argument("--force", action="store_true",
help="overwrite names that have already been set")
args = parser.parse_args()
repo = BeerRepository()
try:
plan = build_plan(repo)
finally:
repo.client.close()
if not plan:
print("No runs found in the readings collection — nothing to do.")
return 0
print(f"{'run':>5} {'name':<38} {'OG':>5} {'readings':>8} state")
print("-" * 78)
for entry in plan:
state = "active" if entry["active"] else "finished"
marker = " " if entry["would_change"] else "*"
print(f"{entry['beer_run_id']:>5} {entry['name'][:38]:<38} "
f"{entry['og_points'] or '-':>5} {entry['readings']:>8} {state}{marker}")
changed = [e for e in plan if e["would_change"] or args.force]
kept = [e for e in plan if not e["would_change"] and not args.force]
print()
print(f"{len(changed)} run(s) would be written, {len(kept)} kept as they are.")
if kept:
print("(* already has a name set by hand — use --force to overwrite)")
print(f"{'APPLYING' if args.apply else 'DRY RUN — nothing written'}")
print()
if not args.apply:
print("Re-run with --apply to write these.")
return 0
repo = BeerRepository()
try:
for entry in changed:
repo.upsert_run_metadata_partial(
entry["beer_run_id"],
name=entry["name"],
og_points=entry["og_points"],
tap_number=entry["tap_number"],
notes=entry["notes"] or None,
)
print(f" wrote run {entry['beer_run_id']}: {entry['name']}")
finally:
repo.client.close()
print(f"\nDone — {len(changed)} run(s) written to the beer_runs collection.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -16,6 +16,26 @@ from flask_wtf import CSRFProtect
__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):
"""Raised when the app is missing configuration it cannot safely default.
@@ -67,6 +87,31 @@ def create_app(config: dict | None = None) -> Flask:
if config:
app.config.update(config)
# The pages must not be cached by the browser.
# A phone held on to a cached copy of the brewing page and showed
# the old inline advice instead of the button and the pop-out, so
# the change was not visible until the cache was cleared by hand.
@app.after_request
def _no_store(response):
if response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
elif response.mimetype in ("text/css", "application/javascript",
"text/javascript"):
# The static files must NEVER be reused from a cache.
# Firefox kept serving a stale stylesheet even with
# `no-cache`, which meant the page had the new markup and
# the old CSS: the advice was expanded inline in the table
# instead of in a pop-out, and the long and short labels
# were both showing ("BREWING ADVICEADVICE").
# `no-store` is the only one that means "do not keep this".
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
csrf = CSRFProtect(app)
# ------------------------------------------------------------------ #
@@ -110,6 +155,20 @@ def create_app(config: dict | None = None) -> Flask:
# ------------------------------------------------------------------ #
# 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)
def _not_found(_exc):
return render_template("error.html", code=404,
@@ -123,11 +182,13 @@ def create_app(config: dict | None = None) -> Flask:
# ------------------------------------------------------------------ #
# Blueprints
# ------------------------------------------------------------------ #
from views.analysis import bp as analysis_bp
from views.auth import bp as auth_bp
from views.brews import bp as brews_bp
app.register_blueprint(auth_bp)
app.register_blueprint(brews_bp)
app.register_blueprint(analysis_bp)
@app.route("/healthz")
def healthz():
+368
View File
@@ -0,0 +1,368 @@
"""Brewing advice from an LLM, generated for each recorded observation.
The advice is a *comment on the reading*, not a replacement for the
brewer's own comment. Every reading gets its own advice text, stored in
its own field, so nothing the brewer wrote is ever overwritten.
Design notes
------------
- **The LLM is never in the request path.** Recording a reading must not
block on a model that may be slow or down, so the advice is generated in
a background thread and written when it is ready. The page shows the
reading immediately and the advice appears on a later visit.
- **Failures are recorded, not raised.** If the model is unreachable the
advice field gets a short note saying so. A silent failure would look
identical to "no advice yet", which is confusing when you are waiting
for a tip that is never going to arrive.
- **The model is configurable**, with a default that is known to work on
this homelab. Several models on the Ollama instance have been retired
and fail at call time while still appearing in the model list, so the
default is verified rather than assumed.
"""
from __future__ import annotations
import json
import logging
import os
import re
import urllib.error
import urllib.request
log = logging.getLogger(__name__)
#: The Ollama instance on the homelab. Reachable from the pod.
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.178.45:11434")
#: The model to use. Override with the ``OLLAMA_MODEL`` environment
#: variable.
#:
#: A model that does NOT reason is much better here. The reasoning
#: models emit their thinking out of the same token allowance as the
#: answer, and several of them burned the whole budget on thinking and
#: returned an EMPTY reply - which looks like a broken model rather than
#: a budget problem. With `think: false` the model still shows its
#: working, but inline in the content, so the answer can be taken from
#: the end of it.
#:
#: `deepseek-v4-flash:cloud`, `deepseek-v3.2:cloud` and `qwen3.5:cloud`
#: are all retired and fail at call time while still appearing in the
#: model list, so the default is one that was probed.
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "mistral-large-4:cloud")
#: How long to wait for the model before giving up. The advice is
#: generated in the background, so this only bounds the background thread.
DEFAULT_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
#: The advice is advice, not instruction. Keep it short - it has to be
#: readable on the brewing page next to the brewer's own comment.
MAX_ADVICE_CHARS = 1200
SYSTEM_PROMPT = """\
You are an experienced home brewer giving practical advice to a single \
brewer about their own beer. You know brewing science: specific gravity, \
attenuation, yeast health, temperature, timing, and what a stalled or \
struck-out fermentation looks like.
You will be given one brew's readings so far: its name, the original \
gravity, each reading's date and specific gravity, and any comment the \
brewer wrote.
Give the brewer useful, specific, practical advice about THIS reading and \
this brew. Consider things like:
- Is the fermentation progressing at a normal rate for the style and \
temperature?
- Has it stalled, and if so what should they do about it?
- Is the yeast likely stressed, unhealthy, or done?
- Is the gravity drop plausible for the original gravity?
- Should they rack, add more hops, raise the temperature, or wait?
Rules:
- Your reply must have this exact shape:
ADVICE:
<the advice itself, two to five sentences of plain prose>
The line "ADVICE:" comes FIRST, on its own line, with nothing before
it. Do not explain your reasoning and do not show your working.
- Advise about the reading that is marked as the one to advise on. It
may not be the most recent one - the brewer may be correcting an older
reading - and the advice must be about the reading they asked about.
- If everything looks normal, say so briefly and say what to expect next.
- If something looks wrong, say what it is and what to do about it.
- Be specific to the numbers given. Do not give generic advice.
- If a reading looks like a typo or a mis-entry, say so and suggest \
checking it, but do not assume it - a slow or long fermentation can be \
genuine.
- Never say a reading is impossible. Beer cannot be denser than water, so \
a gravity below 1.000 is always a mis-entry or a typo, but the rest of the \
run may still be real and worth understanding.
- No preamble, no headings, no bullet-point templates, no "Great question!". \
Write two to five sentences of plain prose.
"""
def _reading_lines(readings: list[dict], subject_id: str | None = None) -> str:
"""The readings, as the compact list the model reads.
The reading being advised about is marked, so the model does not have
to work out which one from the dates - the brewer may be correcting
an older reading.
"""
lines = []
for r in readings:
parts = [f"- {r.get('date')}: gravity {r.get('sg_decimal')} ({r.get('point_scale')})"]
if r.get("comment"):
parts.append(f"brewer's comment: {r['comment']}")
if r.get("final"):
parts.append("(marked as the FINAL reading)")
if subject_id is not None and str(r.get("_id")) == str(subject_id):
parts.append("<<< THIS IS THE READING TO ADVISE ON")
lines.append(" ".join(parts))
return "\n".join(lines)
def build_prompt(run: dict, reading: dict, history: list[dict],
corrected: bool = False) -> str:
"""The user-message content for one observation.
*corrected* says the reading has just been edited, so the advice
should take that into account.
"""
name = run.get("canonical_name") or run.get("name") or "this brew"
og = run.get("og_decimal")
if og is None and run.get("og_points"):
og = f"{run['og_points'] / 1000:.3f}"
parts = [
f"Beer: {name}",
]
if og:
parts.append(f"Original gravity: {og}")
if run.get("tap_number"):
parts.append(f"Fermenter tap: {run['tap_number']}")
if run.get("notes"):
parts.append(f"Brewer's notes: {run['notes']}")
parts.append("")
parts.append("Readings so far (oldest first):")
parts.append(_reading_lines(history, subject_id=reading.get("_id")))
parts.append("")
# The reading being advised about is NAMED, not just described as
# "the most recent one". The brewer may be editing an older reading,
# and the advice is about THAT reading - so the prompt has to say
# which one, or the model advises about the newest instead.
parts.append(
f"Give advice about the reading dated {reading.get('date') or 'unknown'}"
f" (gravity {reading.get('sg_decimal')}),"
+ (" which the brewer has just corrected." if corrected else "")
)
if corrected:
parts.append(
"It is the reading they have just corrected, so it may differ "
"from what it said before."
)
return "\n".join(parts)
def call_ollama(prompt: str, *, url: str | None = None, model: str | None = None,
timeout: float | None = None) -> str:
"""Ask the model for advice. Raises on failure."""
base = (url or DEFAULT_OLLAMA_URL).rstrip("/")
chosen_model = model or DEFAULT_MODEL
wait = timeout if timeout is not None else DEFAULT_TIMEOUT
payload = json.dumps({
"model": chosen_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"temperature": 0.4,
# The reasoning models emit their thinking first, and it comes out
# of the SAME budget as the answer. At 500 tokens the reasoning
# used the whole allowance and the reply came back empty - which
# looks like a broken model rather than a budget problem.
#
# `think: false` does NOT help: the models still think, they just
# put the working in the content instead of a separate field.
# So the budget has to cover the reasoning AND the advice.
"max_tokens": 8000,
"stream": False,
}).encode("utf-8")
request = urllib.request.Request(
f"{base}/v1/chat/completions",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=wait) as response:
body = json.loads(response.read().decode("utf-8"))
choices = body.get("choices") or []
if not choices:
raise RuntimeError("the model returned no choices")
message = choices[0].get("message") or {}
# Some models put their working in a separate field. It is ignored -
# the advice is the content.
content = message.get("content") or ""
content = content.strip()
if not content:
raise RuntimeError("the model returned an empty response")
# A reply cut off at the token ceiling is INCOMPLETE. It may be
# advice that stops mid-sentence, so it is marked as truncated rather
# than stored as if it were the whole answer.
finish_reason = choices[0].get("finish_reason") or body.get("done_reason")
truncated = finish_reason == "length"
advice = _trim_advice(_strip_reasoning(content))
if truncated:
advice = _trim_advice(advice + " (the reply was cut off)")
return advice, truncated
def _trim_advice(text: str) -> str:
"""Cut the advice down to the readable length, keeping the END.
The advice is at the end of the reply, so a plain truncation from the
front would cut the advice off and leave only the model's working -
which is worse than no advice at all, because it looks like advice.
So an over-long reply is cut at a sentence boundary near the end.
"""
if len(text) <= MAX_ADVICE_CHARS:
return text
tail = text[-MAX_ADVICE_CHARS:]
# Start at the first sentence break in the tail, so it does not begin
# mid-word.
for separator in (". ", "? ", "! "):
index = tail.find(separator)
if index != -1 and index < 200:
return tail[index + 2:].strip()
return tail.strip()
#: The marker the model is asked to put at the start of its advice.
#:
#: Guessing where the advice begins by classifying prose is unreliable
#: in both directions - it emptied legitimate advice, and it let the
#: model's working through in full. A marker the model writes itself is
#: deterministic: everything before it is working, everything from it on
#: is the advice.
_ADVICE_MARKER = "ADVICE:"
def _strip_reasoning(text: str) -> str:
"""Take the advice out of a reply.
The model is asked to put ``ADVICE:`` on its own line at the start
of the advice, so the working before it can be dropped exactly, and
the advice kept exactly.
A reply with no marker is used as it stands. That is the safe
direction: the model was asked for the marker, so a reply without
one is one where it wrote the advice straight away - and if the
working did leak in, the brewer sees a slightly rambling reply
rather than no advice at all.
"""
index = _find_marker(text)
if index is None:
return text.strip()
advice = text[index:].strip()
# The model may wrap the marker in markdown or a code block. Strip
# the leftover formatting so the advice reads as prose.
advice = re.sub(r"^\*\*|\*\*$", "", advice).strip()
if advice.startswith("```"):
advice = advice[3:]
if advice.endswith("```"):
advice = advice[:-3]
if advice.startswith("##"):
advice = advice.lstrip("#").strip()
return advice.strip() or ""
def _find_marker(text: str) -> int | None:
"""Where the advice starts, or ``None`` if there is no marker.
The marker is matched case-insensitively and with the markdown
decoration the model might put around it - ``**ADVICE:**``,
``## ADVICE:`` - because the instruction asks for "ADVICE:" but the
model formats it its own way often enough to matter.
"""
match = re.search(r"^[\s#>\*]*advice\s*:[\s\*>]*", text,
re.IGNORECASE | re.MULTILINE)
if match:
return match.end()
return None
def generate_advice(run: dict, reading: dict, history: list[dict], *,
corrected: bool = False,
url: str | None = None, model: str | None = None,
timeout: float | None = None) -> tuple[bool, str]:
"""Generate the advice for one reading.
Returns ``(ok, text)``. On failure ``ok`` is False and ``text`` is a
short note explaining what went wrong, so the reading carries a
visible reason instead of silently having no advice.
"""
prompt = build_prompt(run, reading, history, corrected=corrected)
try:
advice, _truncated = call_ollama(prompt, url=url, model=model,
timeout=timeout)
if not advice:
# The reply had no advice in it - the model wrote only its
# working, or the marker was missing and nothing followed it.
# Storing an empty string with advice_ok=True would render
# as "Thinking..." for ever, so it is recorded as a failure
# with a note the brewer can see.
log.warning("the model returned no advice")
return False, ("Advice could not be generated - the model "
"returned only its reasoning.")
return True, advice
except urllib.error.HTTPError as exc:
# HTTPError is a subclass of URLError, so it has to be caught
# FIRST or it would be swallowed by the unreachable branch.
detail = ""
try:
detail = exc.read().decode("utf-8", "replace")[:300]
except Exception:
pass
log.warning("the model returned HTTP %s: %s", exc.code, detail)
return False, (f"Advice could not be generated - the model returned "
f"HTTP {exc.code}. {detail}".strip())
except urllib.error.URLError as exc:
log.warning("the model was unreachable: %s", exc)
return False, "Advice could not be generated - the model was unreachable."
except TimeoutError:
log.warning("the model timed out")
return False, "Advice could not be generated - the model timed out."
except Exception as exc: # noqa: BLE001 - the caller must never crash
log.warning("advice generation failed: %s", exc)
return False, f"Advice could not be generated - {exc}"
def generate_advice_in_background(run: dict, reading: dict,
history: list[dict], writer,
corrected: bool = False) -> None:
"""Generate the advice and hand it to *writer* when it is ready.
*writer* is called as ``writer(reading_id, ok, text)``. It runs in a
background thread, so it must not touch the request context - the
caller is responsible for giving it a way to reach Mongo.
"""
ok, text = generate_advice(run, reading, history, corrected=corrected)
try:
writer(reading.get("_id"), ok, text)
except Exception: # noqa: BLE001 - never let the thread die noisily
log.exception("could not store the generated advice")
+253 -20
View File
@@ -59,7 +59,9 @@ ABV_FACTOR = 0.13125
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
ASSUMED_ORIGINAL_GRAVITY = 1012
#: Plausible range for a hydrometer reading, in points.
#: Plausible range for a hydrometer reading, in points. The upper bound is
#: 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
_MAX_PLAUSIBLE_SG = 1200
@@ -150,6 +152,14 @@ class Reading:
comment: str = ""
beer_type: str = ""
_id: str = ""
# The LLM's advice for this reading. It lives in its own field in
# Mongo, so the brewer's own comment is never overwritten.
advice: str = ""
advice_ok: bool = True
# Marks this generation of the reading. Every edit writes a fresh
# one, so an advice thread working on an earlier version of the
# reading can be told apart from the current one.
advice_token: str = ""
@property
def sg_decimal(self) -> float | None:
@@ -169,6 +179,9 @@ class Reading:
comment=(doc.get("comment") or "").strip(),
beer_type=(doc.get("beer_type") or "").strip(),
_id=str(doc.get("_id") or ""),
advice=(doc.get("advice") or "").strip(),
advice_ok=bool(doc.get("advice_ok", True)),
advice_token=str(doc.get("advice_token") or ""),
)
@@ -187,18 +200,55 @@ class Run:
# -- aggregate accessors ------------------------------------------------ #
def latest_advice(self) -> str:
"""The advice for the most recent reading that has any.
The advice is generated in the background, so the newest reading
may not have any yet - in which case the newest one that does is
used, rather than showing nothing.
"""
for reading in reversed(self.readings):
if reading.advice:
return reading.advice
return ""
def latest_advice_ok(self) -> bool:
"""False when the advice is a note saying it could not be generated."""
for reading in reversed(self.readings):
if reading.advice:
return reading.advice_ok
return True
def sg_values(self) -> list[int]:
"""Gravity points of every *usable* reading, in chronological order."""
return [r.sg_points for r in self.readings if r.sg_points is not None]
def dated_sg_values(self) -> list[int]:
"""Gravity points of the readings that have a date, chronologically.
An undated reading cannot be placed in the run's timeline, so it
must not be used as the original gravity: it sorts to the front
and would put the OG after the FG.
"""
dated = [r for r in self.readings
if r.sg_points is not None and r.date is not None]
return [r.sg_points for r in dated]
def original_gravity(self) -> int | None:
"""OG = the first usable reading of the run."""
values = self.sg_values()
"""OG = the first *dated* usable reading of the run."""
values = self.dated_sg_values()
return values[0] if values else None
def final_gravity(self) -> int | None:
"""FG = the last usable reading of the run (if the run is finished)."""
values = self.sg_values()
"""FG = the last *dated* usable reading of the run (if it is finished).
For a finished run the final reading is the one flagged as final,
which is the reading the brewer took when they racked it.
"""
final = self.final_reading()
if final is not None and final.sg_points is not None:
return final.sg_points
values = self.dated_sg_values()
return values[-1] if values else None
def max_sg(self) -> int | None:
@@ -228,7 +278,15 @@ class Run:
return og - fg
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()
return abv_percent(drop) if drop is not None else None
@@ -289,20 +347,32 @@ class Run:
def display_name(self) -> str:
"""Best available human label for the run.
The stored ``beer_type`` drifts between readings of the same run, so the
most frequently used non-empty value wins; ties break towards the most
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
rather than "Necatron-Christmas Beer".
The stored ``beer_type`` drifts between readings of the same run, so
the name is resolved by preferring, in order:
1. The name on the **final** reading. That is the one the operator
wrote when the brew was finished, so it is the name they settled
on. Run 4 resolves to "Nocturna Christmas beer" rather than
"Necatron-Christmas Beer" from the first reading.
2. The name used on the most readings, breaking ties towards the most
recent. This catches runs whose name changed exactly once.
3. The name on the most recent reading, if it appears only once.
A run whose readings carry no name at all falls back to "Run N".
"""
counts: dict[str, int] = {}
for reading in self.readings:
label = reading.beer_type
if label:
counts[label] = counts.get(label, 0) + 1
if not counts:
named = [r for r in self.readings if r.beer_type]
if not named:
return f"Run {self.beer_run_id}"
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
return best
final = self.final_reading()
if final is not None and final.beer_type:
return final.beer_type
counts: dict[str, int] = {}
for reading in named:
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1
return max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
def _last_index_of(self, label: str) -> int:
for index in range(len(self.readings) - 1, -1, -1):
@@ -391,12 +461,162 @@ def days_between(start: date, end: date) -> int:
def _fmt_decimal(points: int | None) -> str:
"""Format gravity points as a decimal reading, e.g. ``1039`` -> ``1.039``."""
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
if points is None:
return ""
return f"{points / 1000:.3f}"
def _fmt_point_scale(points: int | None) -> str:
"""Format the gravity in brewing "points".
A reading of 1.039 is 39 points in the homebrew sense - the digits after
the decimal point. The stored value ``1039`` is the full reading, not a
point count, so it must not be labelled "points".
"""
if points is None:
return ""
return f"{points % 1000}"
def fermentation_curve(run: Run) -> list[dict]:
"""The run's gravity readings shaped for plotting.
Each point carries the reading date, the gravity, and how far through
the fermentation it was - the fraction of the total gravity drop that
had happened by that reading. That makes two runs with different
gravities directly comparable, which is the point of the chart.
"""
values = run.sg_values()
if len(values) < 2:
return []
og = values[0]
total_drop = og - values[-1]
if total_drop <= 0:
# No drop to measure progress against.
return []
points = []
for reading in run.readings:
if reading.sg_points is None:
continue
drop = og - reading.sg_points
points.append({
"date": reading.date.isoformat() if reading.date else None,
"sg": reading.sg_points,
"sg_decimal": _fmt_decimal(reading.sg_points),
"point_scale": _fmt_point_scale(reading.sg_points),
# 0.0 at the start, 1.0 at the final reading.
"progress": round(max(0.0, min(1.0, drop / total_drop)), 4),
"final": reading.final_reading,
"comment": reading.comment,
})
return points
def attenuation_percent(run: Run) -> float | None:
"""The apparent attenuation: how much of the original gravity was
fermented away.
This is the number a brewer actually compares between recipes, and it is
more informative than ABV alone because it normalises for how strong the
starting wort was.
Returns ``None`` when the figure would be meaningless:
- The original gravity was not above 1.000, so there is nothing to
attenuate.
- The final gravity is below 1.000. Beer cannot be less dense than
water, so a reading below 1.000 means the reading is wrong (a
mis-entry, or a hydrometer read at the wrong temperature). Run 12 in
the live data has a final gravity of 900, which would otherwise show
as a nonsense 338% attenuation. The run is reported separately by
:func:`implausible_runs` rather than being quietly folded into the
averages.
"""
og = run.original_gravity()
fg = run.final_gravity()
if og is None or fg is None or og <= 1000 or fg < 1000:
return None
if fg > og:
# The gravity went UP. That is not attenuation - it is either a
# mis-ordered pair of readings or a contaminated sample, and a
# negative attenuation is not a number a brewer can act on.
return None
attenuation = (og - fg) / (og - 1000) * 100
if attenuation > 100:
# Cannot ferment away more than the sugar that was there.
return None
return round(attenuation, 1)
def implausible_runs(runs: Iterable[Run]) -> list[dict]:
"""The runs whose gravity readings are physically impossible.
Beer cannot be less dense than water, so a final gravity below 1.000
means a reading is wrong. These runs are excluded from the averages and
listed on the analysis page instead of being silently included.
"""
problems = []
for run in runs:
og = run.original_gravity()
fg = run.final_gravity()
if fg is not None and fg < 1000:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"final gravity {fg / 1000:.3f} is below 1.000 — "
"beer cannot be less dense than water, so this reading "
"is probably wrong"
),
})
elif og is not None and fg is not None and fg > og:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"the final gravity {fg / 1000:.3f} is higher than the "
f"original {og / 1000:.3f} — the gravity went up, which "
"is usually a mis-ordered pair of readings"
),
})
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
and (og - fg) / (og - 1000) * 100 > 100:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": "the gravity drop implies more than 100% attenuation",
})
return problems
def days_to_target(run: Run, target: int) -> int | None:
"""How many days the run took to reach *target* gravity.
Used to answer "how long does this usually take?", which is the question
a brewer actually has when planning the next batch.
"""
first = run.first_reading()
if first is None or first.date is None:
# Without a start date there is no elapsed time to measure.
return None
# Skip the readings that cannot be placed on the timeline (no date)
# rather than giving up on the whole run because of one of them.
for reading in run.readings:
if reading.sg_points is None or reading.sg_points > target:
continue
if reading.date is None:
continue
return days_between(first.date, reading.date)
return None
def run_summary(run: Run, today: date | None = None,
og_override: int | None = None) -> dict:
"""A flat dict describing a run, ready for a template or a JSON response."""
@@ -415,18 +635,29 @@ def run_summary(run: Run, today: date | None = None,
# Pre-formatted for the templates so no arithmetic happens in Jinja.
"og_decimal_str": _fmt_decimal(og),
"fg_decimal_str": _fmt_decimal(fg),
"og_point_scale": _fmt_point_scale(og),
"fg_point_scale": _fmt_point_scale(fg),
"max_sg": run.max_sg(),
"min_sg": run.min_sg(),
"min_sg_decimal": _fmt_decimal(run.min_sg()),
"min_sg_point_scale": _fmt_point_scale(run.min_sg()),
"drop_points": run.gravity_drop_points(),
"abv": run.abv_percent(),
"projected_abv": run.projected_abv_percent(og_override=og_override),
"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),
"first_date": first.date.isoformat() if (first and first.date) else None,
"final_date": final.date.isoformat() if (final and final.date) else None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"last_comment": run.comments()[-1] if run.comments() else "",
# The advice the LLM gave for the most recent reading, if it has
# arrived yet.
"advice": run.latest_advice(),
"advice_ok": run.latest_advice_ok(),
"attenuation": attenuation_percent(run),
"curve": fermentation_curve(run),
"outliers": [r.sg_points for r in run.outlier_readings()],
"readings": [
{
@@ -436,6 +667,8 @@ def run_summary(run: Run, today: date | None = None,
"final_reading": r.final_reading,
"comment": r.comment,
"beer_type": r.beer_type,
"advice": r.advice,
"advice_ok": r.advice_ok,
}
for r in run.readings
],
+288 -3
View File
@@ -18,7 +18,9 @@ Two collections are used:
from __future__ import annotations
import logging
import re
import os
import threading
from typing import Any
from pymongo import MongoClient
@@ -44,6 +46,56 @@ class BeerRepository:
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
# shadow the runs() method on the instance.
self.run_meta = self.db["beer_runs"]
self._ensure_indexes()
def close(self) -> None:
"""Close the underlying Mongo client and release it.
A ``MongoClient`` keeps a connection pool and spawns three
monitoring threads. ``close()`` alone does NOT reap them - they
go when the client object is garbage collected - so the
reference is dropped as well.
The request-scoped repository is closed by the app's teardown, but
a caller that builds its own - the advice background thread, for
instance - has to close it itself or the threads and the pool
accumulate, three per reading.
"""
client = self.client
try:
client.close()
except Exception:
pass
# Drop the reference so the monitor threads can be reaped.
self.client = None
def __enter__(self) -> "BeerRepository":
return self
def __exit__(self, *exc_info) -> None:
self.close()
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 -------------------------------------------------------------- #
@@ -157,14 +209,128 @@ class BeerRepository:
"date": _as_date(reading_date),
"final_reading": "True" if final_reading else "False",
"comment": str(comment),
}
},
# The advice was written for the reading that was there when
# it was generated. If the reading has been edited, that
# advice no longer describes it, so it is cleared and a fresh
# one is generated. (Advice is only ever cleared here - never
# the brewer's comment.)
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""},
}
# A fresh token marks this generation of the reading. The advice
# thread carries the token it was given, so a thread that is
# still working on an EARLIER version of the reading finds a
# token that no longer matches and its write is refused. The
# gravity alone is not enough - a comment-only correction leaves
# the gravity unchanged.
update["$set"]["advice_token"] = _new_token()
result = self.readings.update_one(query, update, upsert=True)
if result.upserted_id is not None:
return str(result.upserted_id)
existing = self.readings.find_one(query)
return str(existing.get("_id")) if existing else ""
def set_reading_advice(self, reading_id: str, advice: str,
ok: bool = True,
expected_sg: int | None = None,
expected_token: str | None = None,
expected_comment: str | None = None) -> int:
"""Attach the LLM's advice to a reading.
The advice lives in its own field, so the brewer's own comment is
never overwritten. ``ok`` says whether the advice was actually
generated or is a note explaining that it could not be.
The write is CONDITIONAL, so a slow advice thread cannot
overwrite a correction:
- ``expected_token`` is the editing token of the reading at the
moment the advice was generated. Every edit writes a fresh
token, so a late thread's token no longer matches.
- ``expected_sg`` is the gravity the advice was generated for,
as a second check.
This matters because the advice is generated in the background: if
the brewer corrects a reading while the advice for the old value
is still being generated, the slow thread would land afterwards
and overwrite the fresh advice with advice about a reading that
no longer exists.
The token is needed as well as the gravity, because a
comment-only correction leaves the gravity unchanged - the token
is what tells the two generations apart.
"""
doc_id = _as_doc_id(reading_id)
if not doc_id:
return 0
query: dict = {"_id": {"$in": doc_id}}
if expected_token:
# The editing token is the PRIMARY guard: it changes on every
# edit, so it catches a stale generation whatever else
# changed - the gravity, the comment, or nothing.
query["advice_token"] = str(expected_token)
elif expected_token == "":
# The reading has NEVER been edited, so it has no token at
# all. Match on the ABSENCE of one, which still tells the
# generations apart: an edit writes a token, so a stale
# thread's "" stops matching the moment the reading is
# corrected.
#
# Falling back to the gravity here would re-open the hole
# this guard exists to close - a comment-only correction
# leaves the gravity unchanged.
# `$in` with null also matches a MISSING field, so this
# covers a reading with no token AND one whose token field
# is present but empty.
query["advice_token"] = {"$in": [None, ""]}
elif expected_sg is not None:
# A direct call with a gravity to check against.
query["sg"] = str(expected_sg)
if expected_comment is not None:
# The comment the advice was written for.
#
# This is a SECOND, independent guard. The token covers
# every edit the application makes, but a document edited
# outside the application - by a script, or by hand in a
# database tool - would keep its empty token and a stale
# thread's write would still match on the gravity alone.
#
# The advice is generated from the comment as well as the
# gravity, so the comment is part of what the advice
# describes. Matching on it means a stale write can only
# land if BOTH the gravity and the comment are unchanged.
#
# The stored comment and the one the view sees can differ by
# trailing whitespace, so a regex is used to match either.
#
# `^comment\s*$` matches the stored value with any trailing
# whitespace, and the exact value too. It is anchored, so it
# cannot match a longer comment that merely starts the same
# way.
query["comment"] = {
"$regex": "^" + re.escape(expected_comment.strip()) + r"\s*$"
}
# `$in` matches either form. A plain list would be an exact
# ARRAY match in Mongo, which is not what is wanted here.
result = self.readings.update_one(
query,
{"$set": {
"advice": str(advice),
"advice_ok": bool(ok),
"advice_at": _now_iso(),
}},
)
# `matched_count`, not `modified_count`.
#
# `modified_count` is 0 when the document matched but the text
# was already identical - which is a successful write, not a
# refused one. The caller logs "the advice was not stored" when
# this is 0, so it has to mean "the guard refused it".
return result.matched_count
def set_final_reading(self, beer_run_id: str, reading_date: str,
final: bool) -> int:
"""Flag (or unflag) a reading as the final reading of its run."""
@@ -196,9 +362,13 @@ class BeerRepository:
"""Create or update the metadata document for a run.
Only the keys passed in are written; anything already stored is left
alone.
alone. A field passed as ``None`` is **cleared** — that is how the
form removes a name or a tap number the operator has deleted.
Use ``upsert_run_metadata_partial`` to update some fields while
leaving the rest untouched.
"""
payload = {k: v for k, v in fields.items() if v is not None}
payload = dict(fields)
payload["updated_at"] = _now_iso()
self.run_meta.update_one(
{"beer_run_id": _as_id(beer_run_id)},
@@ -207,6 +377,38 @@ class BeerRepository:
)
return self.run_metadata(beer_run_id)
def upsert_run_metadata_partial(self, beer_run_id: str, **fields: Any) -> dict:
"""Update only the given fields, leaving every other field alone.
Fields passed as ``None`` are ignored rather than cleared.
"""
payload = {k: v for k, v in fields.items() if v is not None}
if not payload:
return self.run_metadata(beer_run_id)
payload["updated_at"] = _now_iso()
self.run_meta.update_one(
{"beer_run_id": _as_id(beer_run_id)},
{"$set": payload},
upsert=True,
)
return self.run_metadata(beer_run_id)
def next_free_tap_number(self) -> int:
"""The lowest tap number not already in use.
Taps are numbered from 1. A run with no tap number recorded does not
occupy one.
"""
used = {
meta.get("tap_number")
for meta in self.all_run_metadata().values()
if meta.get("tap_number") is not None
}
candidate = 1
while candidate in used:
candidate += 1
return candidate
def delete_run_metadata(self, beer_run_id: str) -> bool:
"""Remove the metadata document for a run (readings are untouched)."""
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
@@ -255,6 +457,82 @@ def _as_id(value: object) -> str:
return ""
#: A process-wide repository, for the code that runs outside a request.
#:
#: A ``MongoClient`` spawns three monitor threads and pymongo does not
#: reap them on ``close()`` - they go only when the client is garbage
#: collected. So building a client per advice write accumulated three
#: threads and a connection pool for every reading recorded. One shared
#: client for the life of the process is what the request-scoped
#: repository does too.
_shared_repository: "BeerRepository | None" = None
#: Guards the check-then-set above.
#:
#: Without it, concurrent advice threads each saw ``None`` and each
#: built their own repository - so the fix reintroduced the very leak it
#: was meant to remove, just bounded by a race instead of by the number
#: of readings. Eight threads on a cold process built eight clients and
#: twenty-five threads.
_shared_repository_lock = threading.Lock()
def get_shared_repository() -> "BeerRepository":
"""The process-wide repository, built on first use.
Thread-safe: the first caller builds it, the rest wait and get the
same one.
"""
global _shared_repository
if _shared_repository is None:
with _shared_repository_lock:
# Checked again inside the lock - another thread may have
# built it while this one was waiting.
if _shared_repository is None:
_shared_repository = BeerRepository()
return _shared_repository
def _as_doc_id(value: object):
"""Coerce a reading document id to something Mongo will match.
The ``_id`` of a reading is not a run id, so the ``_as_id`` coercion
does not apply - it would turn the id into an empty string and the
update would silently match nothing.
Two shapes exist in this database:
- the ORIGINAL app's ids are 24 random CHARACTERS stored as a string
(``l78ZidBJ3QaYn98tvB88x4uM``), and
- ids written by Mongo itself are ``ObjectId`` objects.
So the id is returned in BOTH forms and the query matches either.
Accepting only the ObjectId form would mean the advice was never
stored for any of the existing readings.
Only an ObjectId or a plain string is accepted, so a dict can never
be injected as a query operator.
"""
from bson import ObjectId
if isinstance(value, ObjectId):
return [value]
if isinstance(value, str) and value:
try:
# A 24-character hex string may or may not be the stored
# ObjectId or the stored string, so try both.
return [ObjectId(value), value]
except Exception:
# Not hex - it can only be the original app's string id.
return [value]
# Anything else - a dict, a list, an int, None - is not a reading
# id. An EMPTY LIST is returned rather than None: it is a valid
# `$in` argument that matches nothing, so the helper cannot be
# misused into a query error or into matching every document.
return []
def _as_date(value: object) -> str:
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
if isinstance(value, str):
@@ -277,6 +555,13 @@ def hash_password(password: str) -> str:
return generate_password_hash(password)
def _new_token() -> str:
"""A fresh token, to mark one generation of a reading from the next."""
import secrets
return secrets.token_hex(8)
def _now_iso() -> str:
from datetime import datetime, timezone
+412 -6
View File
@@ -172,7 +172,14 @@ p { margin: 0 0 .75rem; }
}
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
.topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); }
.topbar__user {
color: var(--text-muted);
font-size: .85rem;
font-family: var(--mono);
padding: .15rem .5rem;
border: 1px solid var(--border);
border-radius: 999px;
}
/* ------------------------------------------------------------------ page */
@@ -285,7 +292,7 @@ p { margin: 0 0 .75rem; }
line-height: 1.2;
color: var(--amber-bright);
}
.stat__meta { color: var(--text-muted); font-size: .82rem; }
.stat__meta { color: var(--text-muted); font-size: .83rem; }
/* ----------------------------------------------------------------- forms */
@@ -299,7 +306,7 @@ p { margin: 0 0 .75rem; }
color: var(--text-muted);
margin-bottom: .3rem;
}
.field__hint { color: var(--text-faint); font-size: .78rem; margin-top: .25rem; }
.field__hint { color: var(--text-muted); font-size: .8rem; margin-top: .25rem; }
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
input[type="text"], input[type="password"], input[type="number"],
@@ -444,7 +451,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
/* ---------------------------------------------------------------- tables */
.table-wrap { overflow-x: auto; }
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table.data {
width: 100%;
@@ -473,6 +483,148 @@ table.data tbody tr:hover { background: var(--bg-raised-2); }
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* A heading that is too long for a phone column. The long form shows
on a desktop and the short form on a phone - see the media query.
The rule is on `.short` alone, not on `table.data .short`, so it
applies everywhere the pattern is used - in a table heading, in the
advice button, and on the Active Beers page. When only the table
was covered, the advice button could show both forms at once and
read "Read the adviceAdvice". */
.short { display: none; }
/* On a narrow screen the header wraps onto two rows: the brand and the
sign-out on the first, the nav tabs on the second where they get the
full width.
They used to sit on one row with the brand and the sign-out, and the
last tab was cut off mid-word. */
@media (max-width: 720px) {
.topbar__inner {
flex-wrap: wrap;
row-gap: .5rem;
}
.brand {
order: 1;
}
.topbar__logout {
order: 2;
margin-left: auto;
}
.nav {
order: 3;
flex: 1 0 100%;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
/* A fade at the right edge so a clipped tab reads as
scrollable rather than broken. */
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
}
.nav::-webkit-scrollbar {
display: none;
}
.nav__link {
flex-shrink: 0;
}
/* Tighter tabs, so all four fit on a phone without scrolling.
They still scroll if they have to. */
.nav__link {
padding: .35rem .55rem;
font-size: .8rem;
letter-spacing: .01em;
}
/* The tables stay tables on a phone.
They scroll sideways inside their card, which is what a table
does. They used to be squeezed to fit instead, and with seven
to nine columns on a 390px screen that meant every heading and
every value was truncated to a few characters - a gravity of
"1.0..." and a date of "202..." are worse than useless.
So the columns keep their natural width, the table scrolls, and
the advice is a small button so the column that matters is
narrow. */
table.data {
table-layout: auto;
width: 100%;
}
table.data th,
table.data td {
padding-left: .5rem;
padding-right: .5rem;
}
/* A name and a comment are words, so they get a sensible minimum
width and wrap on a word boundary - not one word per line. */
table.data td.col-name,
table.data td.col-comment {
min-width: 7rem;
max-width: 12rem;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
/* The long form gives way to the short form on a phone, because a
heading that is cut off is worse than a heading that is brief.
The rule is on the span itself rather than on the column, so it
applies everywhere the pattern is used - in a table heading, in
the advice button, and on the Active Beers page. */
.long {
display: none;
}
.short {
display: inline;
}
/* The advice trigger is small on a phone - it is a button, and
the advice itself is in the pop-out. */
table.data .advice-trigger {
font-size: .68rem;
padding: .25rem .4rem;
gap: .2rem;
}
/* The action column holds a button. */
table.data .col-action {
white-space: nowrap;
}
table.data .col-action .btn {
padding-left: .5rem;
padding-right: .5rem;
font-size: .72rem;
}
/* The secondary columns go away on a phone - the details are still
on the run's own page. */
table.data .col-drop,
table.data .col-legacy,
table.data .col-days {
display: none;
}
/* The scroll area keeps a visible scrollbar on a phone, so it is
obvious there is more of the table to the right. */
.table-wrap {
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
}
/* ----------------------------------------------------------------- chips */
.chip {
@@ -491,6 +643,13 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.chip--warn { background: var(--warn-dim); color: var(--warn); }
.chip--danger { background: var(--danger-dim); color: var(--danger); }
.chip--good { background: var(--success-dim); color: var(--success); }
/* A run whose readings are impossible: shown in the table so it can be
corrected, but marked as not counted in the averages. */
.data tbody tr.is-suspect td {
color: var(--text-muted);
background: rgba(232, 163, 61, .06);
}
.chip--tap {
background: var(--bg-raised-2);
border: 1px solid var(--amber-deep);
@@ -579,11 +738,55 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
}
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
.brew-card__meta { color: var(--text-faint); font-size: .8rem; font-family: var(--mono); }
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
.brew-card__stat .stat__label { font-size: .66rem; }
/* The three stats sit side by side, so their captions line up. One
caption ("needs 2nd reading") is two lines and the others are one,
which left the baselines ragged. */
.brew-card__stat {
display: flex;
flex-direction: column;
}
.brew-card__stat .stat__label { font-size: .68rem; }
.brew-card__stat .stat__value { font-size: 1.3rem; }
.brew-card__stat .stat__meta { margin-top: auto; }
.brew-card__spark { height: 46px; }
/* The "start a new beer" tile — fills the last grid slot and gives the
page a way to begin a brew without hunting for the form. */
.brew-card--new {
align-items: center;
justify-content: center;
gap: .6rem;
text-align: center;
text-decoration: none;
color: var(--text-muted);
border-style: dashed;
border-color: var(--border-strong);
min-height: 180px;
}
.brew-card--new:hover {
border-color: var(--amber);
color: var(--text);
text-decoration: none;
}
.brew-card--new::before { display: none; }
.brew-card__new-mark {
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border: 1px solid var(--amber);
border-radius: 50%;
font-size: 1.35rem;
line-height: 1;
color: var(--amber);
}
.brew-card__new-text { display: flex; flex-direction: column; gap: .15rem; }
.brew-card__new-text strong { font-size: .95rem; }
.brew-card__spark--empty {
display: flex;
align-items: center;
@@ -608,6 +811,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.row--between { justify-content: space-between; }
.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 {
padding: 2.5rem 1rem;
text-align: center;
@@ -635,3 +844,200 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
.reading-list__body { flex: 1; min-width: 0; }
.reading-list__comment { color: var(--text-muted); font-size: .84rem; }
/* ------------------------------------------------------------- advice */
/* The LLM's advice. It sits next to the brewer's own comment, never
replaces it, and is visually distinct so the two are never confused. */
.advice {
display: block;
margin-top: .35rem;
padding: .5rem .65rem;
border-left: 2px solid var(--amber);
border-radius: 0 4px 4px 0;
background: var(--amber-dim);
color: var(--text);
font-size: .8125rem;
line-height: 1.5;
}
.advice__label {
display: block;
margin-bottom: .15rem;
font-size: .625rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--amber-text);
}
/* The advice on the active-beers card and the run detail page - the
full text, not the clamped table cell. */
.advice--reading {
margin-top: .5rem;
}
/* Advice that could not be generated - it says why. */
.advice--failed {
border-left-color: var(--danger);
background: var(--danger-dim);
color: var(--text-muted);
}
.advice--failed .advice__label {
color: var(--danger);
}
/* The advice in the table is a compact trigger that pops the full text
out on hover, focus or tap.
It used to be the text itself, clamped to four lines. The advice runs
to several hundred characters, and a cell that wide pushes the table
past the width of a phone, so the column ended up clipped off the
right edge and unreadable without sideways scrolling. */
.advice-cell {
position: relative;
width: 1%;
white-space: nowrap;
}
.advice-trigger {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .25rem .55rem;
border: 1px solid var(--amber);
border-radius: 999px;
background: var(--amber-dim);
color: var(--amber-text);
font: inherit;
font-size: .75rem;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
.advice-trigger:hover,
.advice-trigger:focus-visible {
background: var(--amber);
color: var(--bg);
}
.advice-trigger--failed {
border-color: var(--danger);
background: var(--danger-dim);
color: var(--danger);
}
.advice-trigger__icon {
font-size: .85em;
}
/* The pop-out.
It is `position: fixed` and anchored to the trigger by the script,
not absolutely positioned inside the cell. The table is a scroll
container, and an absolutely positioned panel inside it was clipped
by the container's own overflow - the advice was sliced in half by
the card's edge and the bottom border was cut off.
Fixed positioning takes it out of the table entirely, so it floats
above the page and is never cut by a scroll container. */
.advice-pop {
position: fixed;
z-index: 200;
display: none;
width: min(30rem, 92vw);
max-height: 70vh;
overflow-y: auto;
padding: .8rem .9rem;
border: 1px solid var(--amber);
border-radius: 8px;
background: var(--bg-raised);
box-shadow: 0 12px 32px rgba(0, 0, 0, .55);
text-align: left;
white-space: normal;
}
.advice-pop__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .4rem;
}
.advice-pop__head .advice__label {
margin-bottom: 0;
}
.advice-pop__close {
border: 0;
background: none;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
/* A 44px touch target, which is the smallest that is comfortable
on a phone. The negative margin pulls the panel's padding back
in so the button does not push the layout around, but the
padding is still what makes it easy to hit. */
padding: .9rem;
margin: -.9rem -.9rem -.9rem 0;
}
.advice-pop__close:hover {
color: var(--text);
}
.advice-pop__text {
margin: 0;
font-size: .8125rem;
line-height: 1.55;
color: var(--text);
}
/* The script opens and closes the panel, and places it. The CSS only
decides whether it is shown. */
/* The panel is opened by the script, which removes the `hidden`
attribute and adds `is-open`.
The `hidden` attribute is what makes this fail-safe: if the
stylesheet is stale and does not know about `.advice-pop` at all,
the panel is STILL hidden, so the advice cannot appear expanded
inside the table cell. */
.advice-pop.is-open {
display: block;
}
.advice-pop[hidden] {
display: none;
}
/* On a phone there is no hover, so the trigger is a button the script
toggles, and the panel is pinned to the middle of the screen so it is
readable whatever the table's width. */
@media (max-width: 720px) {
.advice-cell {
width: auto;
}
/* A dimmed backdrop, so the panel reads as sitting over the page
rather than floating in it.
A large spread shadow is used rather than a pseudo element, which
would paint behind the panel's own background. */
.advice-pop {
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .55),
0 12px 32px rgba(0, 0, 0, .55);
}
/* On a phone the panel is centred on the screen rather than
anchored to the trigger - there is not the room to float it
beside the cell, and the trigger may be near the edge. */
.advice-pop {
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%);
}
}
Binary file not shown.
Binary file not shown.
+160
View File
@@ -56,11 +56,171 @@
}
}
/**
* The brewing advice pop-out.
*
* On a desktop the panel opens on hover, which the CSS does. A
* phone has no hover, so the trigger is a button that toggles it -
* and it is a real button, so it works with a keyboard and with a
* screen reader.
*/
/**
* The brewing advice pop-out.
*
* The advice runs to several hundred characters. In a table cell it
* pushed the table past the width of a phone, so it is a compact
* trigger instead and the text is shown in a floating panel.
*
* The panel is position:fixed so it escapes the table's scroll
* container - absolutely positioned inside the cell it was clipped
* by the container and sliced in half by the card's edge.
*
* It opens on hover on a desktop, and on tap on a phone, which has
* no hover. The trigger is a real button, so it also works with a
* keyboard.
*/
function wireAdvicePopouts() {
var triggers = document.querySelectorAll(".advice-trigger");
for (var i = 0; i < triggers.length; i++) {
(function (trigger) {
var panel = trigger.parentNode.querySelector(".advice-pop");
if (!panel) { return; }
var closeTimer = null;
function isPhone() {
return window.matchMedia("(max-width: 720px)").matches;
}
function place() {
if (isPhone()) {
/* The CSS centres it on a phone - clear any
inline position left over from a desktop
resize. */
panel.style.top = "";
panel.style.left = "";
return;
}
var box = trigger.getBoundingClientRect();
var gap = 10;
var height = panel.offsetHeight;
var width = panel.offsetWidth;
var spaceBelow = window.innerHeight - box.bottom - gap;
var spaceAbove = box.top - gap;
/* Prefer below; flip above when there is not enough
room and there is more room above. */
var top;
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
top = box.bottom + gap;
} else {
top = Math.max(gap, box.top - height - gap);
}
/* Keep it on screen horizontally. */
var left = Math.min(box.left, window.innerWidth - width - gap);
left = Math.max(gap, left);
panel.style.top = Math.round(top) + "px";
panel.style.left = Math.round(left) + "px";
}
function open() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
/* The `hidden` attribute is removed as well as the
class added, so the panel is shown even if the
stylesheet is an older one that only knows about
the class. */
panel.removeAttribute("hidden");
panel.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
place();
}
function close() {
panel.classList.remove("is-open");
/* Put the attribute back, so a stale stylesheet
cannot leave the advice expanded in the cell. */
panel.setAttribute("hidden", "");
trigger.setAttribute("aria-expanded", "false");
}
function closeSoon() {
/* A short delay, so the mouse can travel from the
trigger into the panel without it closing. */
if (closeTimer) { clearTimeout(closeTimer); }
closeTimer = setTimeout(function () {
closeTimer = null;
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
close();
}
}, 180);
}
trigger.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
if (panel.classList.contains("is-open")) {
close();
} else {
open();
}
});
trigger.addEventListener("mouseenter", function () {
if (!isPhone()) { open(); }
});
trigger.addEventListener("mouseleave", closeSoon);
panel.addEventListener("mouseenter", function () {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
});
panel.addEventListener("mouseleave", closeSoon);
var closeButton = panel.querySelector(".advice-pop__close");
if (closeButton) {
closeButton.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
close();
trigger.focus();
});
}
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && panel.classList.contains("is-open")) {
close();
trigger.focus();
}
});
document.addEventListener("click", function (event) {
if (!panel.contains(event.target) && event.target !== trigger) {
close();
}
});
window.addEventListener("resize", function () {
if (panel.classList.contains("is-open")) { place(); }
});
window.addEventListener("scroll", function () {
if (panel.classList.contains("is-open")) { close(); }
}, true);
})(triggers[i]);
}
}
function init() {
focusFirstEmpty();
defaultDateFields();
wireToggles();
wireConfirms();
wireAdvicePopouts();
}
if (document.readyState === "loading") {
File diff suppressed because one or more lines are too long
+649
View File
@@ -0,0 +1,649 @@
/* ==========================================================================
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; }
fetchRuns()
.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.
//
// A suspect run is left out too. Its ABV comes from an
// impossible reading (run 12 reads 18.64%), and plotting it
// stretched the y axis so that every real bar was squashed
// into the bottom of the plot - while the "Strongest" tile
// on the same page correctly showed 8.53%. The chart and
// the tile contradicted each other.
var completed = runs.filter(function (r) {
return !r.suspect &&
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; }
fetchRuns()
.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; }
fetchRuns()
.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.
//
// Active runs are excluded as well. Their attenuation is
// measured against their CURRENT gravity, so a brew that
// has just started shows a 0% bar - which is not a result,
// it is the absence of one. The chart is about finished
// brews, and it sits above the table of finished brews.
var done = runs.filter(function (r) {
return !r.suspect && !r.active &&
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; }
fetchRuns()
.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.
// Only FINISHED brews belong on the curve. An active run's
// curve is a partial fermentation, and the curve is meant to
// describe how a completed fermentation behaves - including
// a partial one pulls the median line off the real shape.
var withCurve = runs.filter(function (r) {
return !r.suspect && !r.active &&
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 = [];
var envelopeMax = [];
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]
});
envelopeMax.push({ x: pct, y: 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: [
{
// The band is drawn as TWO datasets: the min
// curve filled down to the max curve. With
// a single dataset and `fill: "+1"` the band
// was filled to the NEXT dataset - which was
// the median line, not the max - so the
// tooltip and the shading disagreed.
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
},
{
// The upper edge of the band. It is the
// dataset the one above fills to.
label: "Range max",
data: envelopeMax,
borderColor: withAlpha(ACCENT, 0.35),
backgroundColor: "transparent",
borderWidth: 1,
pointRadius: 0,
fill: false,
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 },
// The max curve is the helper the band
// fills to, not a series of its own.
filter: function (item, data) {
return item.text !== "Range max";
}
}
},
tooltip: {
callbacks: {
label: function (ctx) {
var point = ctx.parsed;
if (ctx.datasetIndex === 0) {
return "range " + point.y +
" to " + (point.yMax || point.y);
}
if (ctx.datasetIndex === 1) {
return null;
}
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); });
}
/* ------------------------------------------------- shared run payload */
// The analysis page has two charts that both need /api/runs, and the
// payload contains every reading of every run. Fetch it once and
// share it rather than making two identical round-trips.
var runsPromise = null;
function fetchRuns() {
if (!runsPromise) {
runsPromise = fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.catch(function (err) {
console.warn(err);
runsPromise = null;
return [];
});
}
return runsPromise;
}
function init() {
drawSparklines();
drawRunChart();
drawAbvChart();
drawMonthsChart();
drawAttenuationChart();
drawCurveChart();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+138
View File
@@ -0,0 +1,138 @@
{% 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&nbsp;reading{% else %} &middot; {{ run.reading_count }}&nbsp;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 %}
{% if run.advice %}
<div class="advice advice--reading{% if not run.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ run.advice }}
</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 %}
+223
View File
@@ -0,0 +1,223 @@
{% 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 class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="num col-abv" title="Attenuation"><span class="long">Attenuation</span><span class="short">Att.</span></th>
<th class="num col-abv col-days">Days</th>
<th class="num col-abv" title="Days to reach 1.010"><span class="long">To 1.010</span><span class="short">1.010</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
<td class="col-gravity num">
{% if row.og_points is not none %}
{{ '%.3f' % (row.og_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="col-gravity num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="col-abv num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="col-abv num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="col-abv col-days num">{{ row.days if row.days is not none else '—' }}</td>
<td class="col-abv num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="col-finished 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 class="col-run">Run</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="col-comment">Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="col-run mono">
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
</td>
<td class="col-gravity num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="col-comment 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.counted_attenuation }} 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 %}
+9 -1
View File
@@ -19,11 +19,17 @@
<nav class="nav" aria-label="Main">
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
href="{{ url_for('brews.index') }}">Brew Process</a>
<a class="nav__link {% if current_path == '/active' %}is-active{% endif %}"
href="{{ url_for('analysis.active') }}">Active Beers</a>
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
href="{{ url_for('analysis.history') }}">History</a>
<a class="nav__link {% if current_path == '/analysis' %}is-active{% endif %}"
href="{{ url_for('analysis.deep') }}">Analysis</a>
</nav>
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span class="topbar__user">{{ session.get('username', '') }}</span>
<span class="topbar__user" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span>
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
</form>
</div>
@@ -44,7 +50,9 @@
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/chart.umd.js') }}"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+58 -31
View File
@@ -78,40 +78,67 @@
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">Gravity</th>
<th class="num col-abv" title="Estimated alcohol by volume"><span class="long">Est. ABV</span><span class="short">ABV</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-advice" title="Brewing advice"><span class="long">Brewing advice</span><span class="short">Advice</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in active_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg }}</span>
<div class="faint tiny">{{ run.min_sg_decimal }}</div>
<span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny point-scale">{{ run.min_sg_point_scale }} points</div>
{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.projected_abv is not none %}
{{ '%.2f' % run.projected_abv }}%
{% else %}
<span class="faint" title="Record the original gravity for this run to get an estimate">—</span>
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
{% endif %}
</td>
<td class="comment">
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<td class="advice-cell col-advice">
{% if run.advice %}
<button type="button"
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
aria-expanded="false">
<span class="advice-trigger__icon" aria-hidden="true">&#9998;</span>
<span class="advice-trigger__label">
{% if run.advice_ok %}
<span class="long">Read the advice</span><span class="short">Advice</span>
{% else %}
<span class="long">Advice failed</span><span class="short">Failed</span>
{% endif %}
</span>
</button>
<div class="advice-pop" role="tooltip" hidden>
<div class="advice-pop__head">
<span class="advice__label">Brewing advice</span>
<button type="button" class="advice-pop__close"
aria-label="Close">&times;</button>
</div>
<p class="advice-pop__text">{{ run.advice }}</p>
</div>
{% else %}
<span class="faint">Thinking…</span>
{% endif %}
</td>
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
@@ -140,41 +167,41 @@
<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>Finished</th>
<th>Comment</th>
<th></th>
<th class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-abv">ABV</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in completed_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
<td class="mono col-run">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="num col-gravity">
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-gravity">
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
</td>
<td class="num">
<td class="num col-abv">
{% if run.abv is not none %}
<strong>{{ '%.2f' % run.abv }}%</strong>
{% else %}—{% endif %}
</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
<td class="mono nowrap col-finished">{{ run.final_date or '—' }}</td>
<td class="comment col-comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<td class="right col-action">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
+1 -1
View File
@@ -3,7 +3,7 @@
{% block title %}{{ code }} — Brew Log{% endblock %}
{% block content %}
<div class="card" style="margin-top:2rem">
<div class="card mt-lg">
<div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<h1>{{ code }}</h1>
+213
View File
@@ -0,0 +1,213 @@
{% 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">
{% if stats.abv_counted < stats.abv_total %}
across {{ stats.abv_counted }} of {{ stats.abv_total }} finished brews
{% else %}
across finished brews
{% endif %}
</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 class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished">Date</th>
<th class="num col-gravity">Reading</th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
</tr>
</thead>
<tbody>
{% for row in outliers %}
<tr>
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-finished mono nowrap">{{ row.date or '—' }}</td>
<td class="col-gravity num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="col-comment 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 class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="col-finished" title="Status"><span class="long">Status</span><span class="short">Stat</span></th>
<th class="num col-gravity">OG</th>
<th class="num col-gravity">FG</th>
<th class="num col-drop">Drop</th>
<th class="num col-abv">ABV</th>
<th class="num col-legacy">Legacy</th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
<th class="col-comment" title="Comment"><span class="long">Comment</span><span class="short">Note</span></th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
{% for run in summaries %}
<tr>
<td class="col-run mono">{{ run.beer_run_id }}</td>
<td class="col-name">{{ run.canonical_name }}</td>
<td class="col-finished">
{% if run.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</td>
<td class="col-drop col-gravity num">{{ run.og_points or '—' }}</td>
<td class="col-gravity num">{{ run.fg_points or '—' }}</td>
<td class="col-gravity num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="col-abv 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="col-legacy col-abv num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="col-finished mono nowrap">{{ run.final_date or '—' }}</td>
<td class="col-comment comment">
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
</td>
<td class="right col-action">
<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 class="col-run">Run</th>
<th class="col-name">Name</th>
<th class="num col-abv" title="Standard ABV"><span class="long">Standard ABV</span><span class="short">Std</span></th>
<th class="num col-legacy col-abv" title="Legacy ABV"><span class="long">Legacy ABV</span><span class="short">Old</span></th>
<th class="num col-abv" title="Overstated by"><span class="long">Overstated by</span><span class="short">Over</span></th>
<th class="col-finished" title="Finished"><span class="long">Finished</span><span class="short">Done</span></th>
</tr>
</thead>
<tbody>
{% for row in abv_rows %}
<tr>
<td class="col-run mono">{{ row.beer_run_id }}</td>
<td class="col-name">{{ row.name }}</td>
<td class="col-abv num">{{ '%.2f' % row.abv }}%</td>
<td class="col-abv num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="col-abv 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="col-finished mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+74 -8
View File
@@ -22,14 +22,57 @@
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a>
</div>
<div class="grid grid--4" style="margin-bottom:1.25rem">
<div class="grid grid--sidebar mb-lg">
<!-- ------------------------------------------------- run metadata -->
<section class="card">
<div class="card__head">
<h2 class="card__title">Run details</h2>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label class="field__label" for="name">Beer / cider name</label>
<input type="text" id="name" name="name" maxlength="120"
value="{{ meta_form.name.data or '' }}">
<div class="field__hint">Overrides the drifting name on each reading.</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="tap_number">Tap number</label>
<input type="number" id="tap_number" name="tap_number"
min="1" max="99" step="1"
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
</div>
<div class="field">
<label class="field__label" for="og_points">Original gravity</label>
<input type="number" id="og_points" name="og_points"
min="900" max="1300" step="1"
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
<div class="field__hint">Points, e.g. 1045.</div>
</div>
</div>
<div class="field">
<label class="field__label" for="notes">Brew notes</label>
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
</div>
<button class="btn btn--block" type="submit">Save run details</button>
</form>
</div>
</section>
<div class="grid grid--4">
<div class="stat">
<div class="stat__label">Original gravity</div>
<div class="stat__value">
{% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %}
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.og_decimal %}
<div class="stat__meta">{{ summary.og_decimal_str }}</div>
{% if summary.og_points is not none %}
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
@@ -37,10 +80,10 @@
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
</div>
<div class="stat__value">
{% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %}
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.fg_decimal %}
<div class="stat__meta">{{ summary.fg_decimal_str }}</div>
{% if summary.fg_points is not none %}
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
@@ -53,7 +96,13 @@
{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if summary.abv is not none %}measured{% else %}projected{% endif %}
{% if summary.abv is not none %}
measured
{% elif summary.projected_abv is not none %}
projected
{% else %}
needs 2nd reading
{% endif %}
</div>
</div>
<div class="stat">
@@ -63,8 +112,19 @@
</div>
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</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">
<div class="card__head">
<h2 class="card__title">Readings</h2>
@@ -84,6 +144,12 @@
{% else %}
<div class="faint tiny">No comment</div>
{% endif %}
{% if reading.advice %}
<div class="advice advice--reading{% if not reading.advice_ok %} advice--failed{% endif %}">
<span class="advice__label">Brewing advice</span>
{{ reading.advice }}
</div>
{% endif %}
</div>
{% if reading.final_reading %}
<span class="chip chip--done">Final</span>
+299
View File
@@ -0,0 +1,299 @@
"""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, attenuation_percent, days_to_target, implausible_runs,
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
@bp.route("/analysis")
@login_required
def deep():
"""The deeper analysis: how the brews compare, and how long they take.
This is the page that answers the questions the history page cannot:
how consistent is my attenuation, which brew fermented fastest, and what
do the gravity curves look like side by side.
"""
runs = repo().runs()
metadata = repo().all_run_metadata()
finished = [r for r in runs if not r.is_active()]
# Runs whose gravity readings are physically impossible are excluded from
# EVERY aggregate and listed separately, rather than being silently
# folded in. A final gravity below 1.000 would otherwise show as a
# 338% attenuation AND a 56-day brew AND the strongest beer in the
# history - one bad reading poisoning three different statistics.
suspect = implausible_runs(finished)
suspect_ids = {s["beer_run_id"] for s in suspect}
# A run that is still fermenting can also have an impossible reading,
# and the API flags those too - so the charts leave them out. If they
# were not listed here they would disappear from the analysis page
# entirely, with no explanation anywhere.
active_suspect = implausible_runs([r for r in runs if r.is_active()])
if active_suspect:
suspect = suspect + active_suspect
suspect_ids |= {s["beer_run_id"] for s in active_suspect}
rows = []
for run in finished:
meta = metadata.get(run.beer_run_id, {})
summary = _decorate(run, metadata)
rows.append({
"beer_run_id": run.beer_run_id,
"name": meta.get("name") or run.display_name(),
"final_date": summary["final_date"],
"first_date": summary["first_date"],
"og_points": summary["og_points"],
"fg_points": summary["fg_points"],
"abv": summary["abv"],
"attenuation": attenuation_percent(run),
"days": summary["days_in_fermenter"],
"days_to_1010": days_to_target(run, 1010),
"readings": summary["reading_count"],
"curve": summary["curve"],
# A suspect run is shown in the table so it can be corrected,
# but it contributes to none of the averages.
"suspect": run.beer_run_id in suspect_ids,
})
# Only the trustworthy runs feed the statistics.
good = [r for r in rows if not r["suspect"]]
attenuations = [r["attenuation"] for r in good if r["attenuation"] is not None]
durations = [r["days"] for r in good if r["days"] is not None]
to_1010 = [r["days_to_1010"] for r in good if r["days_to_1010"] is not None]
# --- consistency ------------------------------------------------------- #
# How much the attenuation varies. A tight spread means the process is
# repeatable; a wide one means something is inconsistent.
consistency = None
if len(attenuations) >= 2:
mean = sum(attenuations) / len(attenuations)
variance = sum((a - mean) ** 2 for a in attenuations) / len(attenuations)
consistency = {
"mean": round(mean, 1),
"stdev": round(variance ** 0.5, 1),
"spread": round(max(attenuations) - min(attenuations), 1),
}
fastest = min((r for r in good if r["days"] is not None),
key=lambda r: r["days"], default=None)
slowest = max((r for r in good if r["days"] is not None),
key=lambda r: r["days"], default=None)
return render_template(
"analysis.html",
rows=rows,
suspect=suspect,
stats={
"count": len(rows),
"suspect_count": len(suspect),
# How many runs the figures are actually based on.
"trusted_count": len(good),
# How many of those actually contributed a figure to the
# average - a run with no original gravity has no attenuation,
# so it is counted above but not in the mean.
"counted_attenuation": len(attenuations),
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
if attenuations else None,
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
"avg_days_to_1010": _fmt_days(sum(to_1010) / len(to_1010)) if to_1010 else None,
"reached_1010": len(to_1010),
"consistency": consistency,
"fastest": fastest,
"slowest": slowest,
},
)
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"]]
# The Run objects for the finished brews, for the plausibility check.
completed_runs = [run for run in runs if not run.is_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"],
})
# A run with an impossible final gravity produces an impossible ABV
# (run 12 reads 18.64%), which would make it the "strongest" beer in
# the history and drag the average up. Those runs are excluded from
# the ABV figures here and listed on the analysis page.
history_suspect = {s["beer_run_id"] for s in implausible_runs(completed_runs)}
good_abv_rows = [r for r in abv_rows if r["beer_run_id"] not in history_suspect]
avg_abv = round(sum(r["abv"] for r in good_abv_rows) / len(good_abv_rows), 2) \
if good_abv_rows else 0
best = max(good_abv_rows, key=lambda r: r["abv"]) if good_abv_rows else None
worst = min(good_abv_rows, key=lambda r: r["abv"]) if good_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,
# How many of the finished brews the ABV figures are based
# on, so the tile can say so.
"abv_counted": len(good_abv_rows),
"abv_total": len(completed),
},
)
+287 -18
View File
@@ -2,14 +2,21 @@
from __future__ import annotations
from flask import (Blueprint, flash, redirect, render_template, request,
session, url_for)
import logging
import os
import threading
from flask import (Blueprint, current_app, flash, jsonify, redirect,
render_template, request, session, url_for)
from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from brewing import is_plausible_sg, run_summary
import brew_advice
from brewing import implausible_runs, is_plausible_sg, run_summary
log = logging.getLogger(__name__)
bp = Blueprint("brews", __name__)
@@ -34,6 +41,139 @@ def login_required(fn):
return wrapper
def advice_enabled() -> bool:
"""Whether the LLM advice should be generated.
It is off in testing, so the suite never reaches out to a model, and
it can be turned off in production with ``LLM_ADVICE=0`` if the model
is misbehaving.
"""
if current_app.config.get("TESTING"):
return False
return os.getenv("LLM_ADVICE", "1").strip().lower() not in {"0", "false", "no"}
def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None:
"""Generate the LLM's advice for a reading, in the background.
The advice is a separate field on the reading, so the brewer's own
comment is never touched.
The background thread cannot use the request-scoped repository - that
client is closed when the request ends - so it uses the shared
process-wide one.
"""
if not reading_id:
return
if not advice_enabled():
return
try:
repository = repo()
run = repository.run(run_id)
if run is None:
return
meta = repository.run_metadata(run_id)
summary = run_summary(run, og_override=meta.get("og_points"))
run_info = {
"canonical_name": meta.get("name") or run.display_name(),
"name": run.display_name(),
"og_points": summary.get("og_points"),
"og_decimal": summary.get("og_decimal"),
"tap_number": meta.get("tap_number"),
"notes": meta.get("notes", ""),
}
# The model's view of the run: every reading, with the gravity in
# both forms a brewer would recognise.
readings = []
for reading in run.readings:
points = reading.sg_points
readings.append({
"_id": str(reading._id),
"advice_token": reading.advice_token,
"date": reading.date.isoformat() if reading.date else "",
"sg": points,
"sg_decimal": reading.sg_decimal,
"point_scale": f"{points} points" if points is not None else "",
"comment": reading.comment or "",
"final": bool(reading.final_reading),
})
# The reading that is actually being advised about. It is not
# necessarily the newest one: the brewer may be editing an older
# reading, and advice about the newest reading would be about the
# wrong number.
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
if saved is None:
# The reading is not in the run. Advising about the newest
# reading instead would describe the wrong number, and the
# conditional write would then refuse it anyway - so it is
# better to say so plainly and stop.
log.info(
"no advice for reading %s: it is not one of the run's readings",
reading_id,
)
return
latest = dict(saved)
latest["_id"] = reading_id
except Exception: # noqa: BLE001 - advice must never break the save
log.exception("could not gather the reading for the advice")
return
# The gravity and the editing token of the reading being advised
# about. The write is conditional on both, so a slow advice thread
# cannot overwrite a correction with advice about the old value.
advice_sg = latest.get("sg")
advice_token = latest.get("advice_token")
# The comment is part of what the advice describes, so it is a
# second, independent guard on the write. The token covers every
# edit the application makes; this also covers a document edited
# outside it.
advice_comment = latest.get("comment") or ""
def write(read_id: str, ok: bool, text: str) -> None:
# The client is SHARED, not built per reading. A MongoClient
# spawns three monitor threads and pymongo does not reap them
# on close(), so building one per advice write accumulated three
# threads and a connection pool for every reading recorded.
#
# The shared client is created on first use and lives for the
# life of the process, which is what the request-scoped
# repository does too.
from mongo.repository import get_shared_repository
try:
shared = get_shared_repository()
written = shared.set_reading_advice(
read_id, text, ok,
expected_sg=advice_sg,
expected_token=advice_token,
expected_comment=advice_comment,
)
if not written:
# The reading was corrected while the advice was being
# generated, so the advice no longer matches it. That is
# the guard working - the fresh advice for the corrected
# reading will land in its own turn.
log.info(
"the advice for reading %s was not stored: the reading "
"has been edited since the advice was generated, so its "
"editing token or comment no longer matches the advice",
read_id,
)
except Exception: # noqa: BLE001 - never let the thread die noisily
log.exception("could not store the generated advice")
thread = threading.Thread(
target=brew_advice.generate_advice_in_background,
args=(run_info, latest, readings, write, corrected),
daemon=True,
)
thread.start()
def _last_reading_date(run) -> str | None:
"""The ISO date of the most recent reading in a run."""
last = run.last_reading()
@@ -49,13 +189,34 @@ class ReadingForm(FlaskForm):
beer_run_type = StringField("Beer / cider name",
validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()])
# The floor is deliberately lower than is_plausible_sg's. An
# out-of-range reading is SAVED with a warning rather than rejected:
# the brewer may have mis-typed it, and seeing it recorded is how they
# spot and correct the mistake.
sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1300)])
validators=[DataRequired(), NumberRange(min=900, max=1500)])
final_reading = BooleanField("This is the final reading")
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
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"])
@login_required
def index():
@@ -65,13 +226,35 @@ def index():
if form.validate_on_submit():
sg_points = int(form.sg.data)
if not is_plausible_sg(sg_points):
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
repo().upsert_reading(
beer_run_id=str(form.beer_run_id.data),
if sg_points < 1000:
# Beer cannot be less dense than water, so this is always
# a mis-entry - and a mis-typed "1.012" as "012" is the
# likely cause. It is still saved so the mistake is
# visible and can be corrected.
flash(
f"{sg_points} is below 1.000 — beer cannot be less dense "
"than water, so this is almost certainly a typo (a 1.012 "
"reading entered as 012?). It was saved anyway; correct "
"it on the run's page.",
"warning",
)
else:
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
run_id = str(form.beer_run_id.data)
# Whether the reading was already there: a re-submitted reading
# is a CORRECTION, and the advice should say so.
existing = repo().reading_docs_for_run(run_id)
was_existing = any(
str(doc.get("date")) == form.Date.data.isoformat()
for doc in existing
)
reading_id = repo().upsert_reading(
beer_run_id=run_id,
beer_type=(form.beer_run_type.data or "").strip(),
sg=sg_points,
reading_date=form.Date.data.isoformat(),
@@ -79,14 +262,23 @@ def index():
comment=(form.comment.data or "").strip(),
)
flash("Reading saved.", "success")
# The advice is generated in the background: recording a reading
# must not block on the model. The page shows the reading straight
# away and the advice is there on a later visit.
_start_advice(run_id, reading_id, corrected=was_existing)
return redirect(url_for("brews.index"))
runs = repo().runs()
metadata = repo().all_run_metadata()
summaries = []
for run in runs:
summary = run_summary(run)
summary["canonical_name"] = run.display_name()
meta = metadata.get(run.beer_run_id, {})
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["notes"] = meta.get("notes", "")
summary["last_date"] = _last_reading_date(run)
summaries.append(summary)
@@ -103,22 +295,99 @@ def index():
summaries=summaries,
active_runs=active,
completed_runs=completed,
next_tap=repo().next_free_tap_number(),
)
@bp.route("/runs/<run_id>")
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
@login_required
def run_detail(run_id: str):
"""One run in detail, with all of its readings."""
"""One run in detail, with its readings and its editable metadata."""
run = repo().run(run_id)
if run is None:
flash(f"No run numbered {run_id}.", "error")
return redirect(url_for("brews.index"))
summary = run_summary(run)
summary["canonical_name"] = run.display_name()
meta = repo().run_metadata(run_id)
meta_form = RunMetaForm()
return render_template("run_detail.html", run=run, summary=summary)
if meta_form.validate_on_submit():
repo().upsert_run_metadata(
run_id,
name=(meta_form.name.data or "").strip() or None,
tap_number=meta_form.tap_number.data,
og_points=meta_form.og_points.data,
notes=(meta_form.notes.data or "").strip() or None,
)
flash("Run details saved.", "success")
return redirect(url_for("brews.run_detail", run_id=run_id))
if not meta_form.is_submitted():
meta_form.name.data = meta.get("name") or run.display_name()
meta_form.tap_number.data = meta.get("tap_number")
meta_form.og_points.data = meta.get("og_points")
meta_form.notes.data = meta.get("notes", "")
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["notes"] = meta.get("notes", "")
return render_template("run_detail.html", run=run, summary=summary,
meta_form=meta_form, meta=meta)
@bp.route("/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()
# The runs whose readings are physically impossible. They are flagged
# rather than dropped: the charts use the flag to leave them out of the
# averages and the curve envelope, but a caller can still see them.
#
# The flag is computed over EVERY run, not just the finished ones, so
# it agrees with what the charts do. An active run with a bad reading
# is still a run with a bad reading.
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
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)
summary["suspect"] = run.beer_run_id in suspect_ids
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")
# The same flag the list endpoint carries, so a consumer of the detail
# endpoint can tell an impossible run from a good one.
summary["suspect"] = bool(implausible_runs([run]))
summary["active"] = run.is_active()
return jsonify(summary)
@bp.route("/runs/<run_id>/final", methods=["POST"])
+11
View File
@@ -53,6 +53,17 @@ def repo(mongo_client):
return BeerRepository(client=mongo_client)
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
def seed_readings(repository, rows):
"""Insert reading documents directly, bypassing the app.
+628
View File
@@ -0,0 +1,628 @@
"""Tests for the deeper analysis: attenuation, fermentation curves, timings."""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from brewing import (Run, attenuation_percent, days_to_target,
fermentation_curve, implausible_runs)
from conftest import seed_readings
def _doc(run_id="1", beer_type="Ale", sg="1045", date_="2026-01-01",
final="False", comment=""):
return {
"_id": f"id-{run_id}-{date_}-{sg}",
"beer_run_id": run_id,
"beer_type": beer_type,
"sg": sg,
"date": date_,
"final_reading": final,
"comment": comment,
}
# ------------------------------------------------------------ attenuation #
def test_attenuation_percent():
"""Attenuation is the fraction of the original gravity fermented away."""
# OG 1050, FG 1010: 40 of the 50 points above 1.000 went away.
run = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) == 80.0
def test_attenuation_percent_of_a_full_fermentation():
"""A wort that finishes at 1.000 has 100% attenuation."""
run = Run.from_docs("2", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1000", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) == 100.0
def test_attenuation_percent_needs_a_gravity_above_1000():
"""A run that started at or below 1.000 has no meaningful attenuation."""
run = Run.from_docs("3", [
_doc(sg="1000", date_="2026-01-01"),
_doc(sg="0995", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) is None
def test_attenuation_percent_of_an_unfinished_run_uses_its_current_gravity():
"""The current gravity stands in for the final one until the run ends."""
run = Run.from_docs("4", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-10"),
])
assert attenuation_percent(run) == 60.0
# ------------------------------------------------------- fermentation curve #
def test_fermentation_curve_is_empty_for_a_single_reading():
"""One reading is no curve."""
run = Run.from_docs("5", [_doc(sg="1045", date_="2026-01-01")])
assert fermentation_curve(run) == []
def test_fermentation_curve_progresses_from_zero_to_one():
"""The curve is the fraction of the gravity drop completed."""
run = Run.from_docs("6", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1030", date_="2026-01-10"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
curve = fermentation_curve(run)
assert len(curve) == 3
assert curve[0]["progress"] == 0.0
assert curve[1]["progress"] == 0.5
assert curve[2]["progress"] == 1.0
def test_fermentation_curve_carries_the_reading_details():
run = Run.from_docs("7", [
_doc(sg="1050", date_="2026-01-01", comment="brew day"),
_doc(sg="1010", date_="2026-01-20", final="True", comment="kegging"),
])
curve = fermentation_curve(run)
assert curve[0]["date"] == "2026-01-01"
assert curve[0]["sg_decimal"] == "1.050"
assert curve[0]["point_scale"] == "50"
assert curve[0]["comment"] == "brew day"
assert curve[1]["final"] is True
def test_fermentation_curve_is_empty_when_the_gravity_never_falls():
"""A run that only goes up has no drop to measure progress against."""
run = Run.from_docs("8", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-10", final="True"),
])
assert fermentation_curve(run) == []
# ------------------------------------------------------------- days to target #
def test_days_to_target():
"""How long the brew took to reach a given gravity."""
run = Run.from_docs("9", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-08"),
_doc(sg="1005", date_="2026-01-20", final="True"),
])
# 1020 is still above 1015, so the run does not reach 1015 until the
# third reading, 19 days in.
assert days_to_target(run, 1015) == 19
assert days_to_target(run, 1010) == 19
# 1020 is below 1025, so that target is reached on the second reading.
assert days_to_target(run, 1025) == 7
assert days_to_target(run, 1000) is None
def test_days_to_target_returns_none_when_never_reached():
run = Run.from_docs("10", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1040", date_="2026-01-20", final="True"),
])
assert days_to_target(run, 1010) is None
# ------------------------------------------------------------------- summary #
def test_run_summary_includes_the_new_analysis_fields():
from brewing import run_summary
run = Run.from_docs("11", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
summary = run_summary(run)
assert summary["attenuation"] == 80.0
assert len(summary["curve"]) == 2
assert summary["curve"][0]["progress"] == 0.0
def test_attenuation_is_none_for_a_final_gravity_below_water():
"""Beer cannot be less dense than water. Run 12 in the live data has a
final gravity of 900, which would otherwise show as 338% attenuation."""
run = Run.from_docs("12", [
_doc(sg="1042", date_="2025-10-05"),
_doc(sg="900", date_="2025-11-30", final="True"),
])
assert attenuation_percent(run) is None
def test_attenuation_is_none_when_it_would_exceed_100_percent():
run = Run.from_docs("13", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1000", date_="2026-01-20", final="True"),
])
# 1050 -> 1000 is exactly 100%, which is the limit but still valid.
assert attenuation_percent(run) == 100.0
run2 = Run.from_docs("14", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run2) == 80.0
def test_implausible_runs_are_identified():
from brewing import implausible_runs
good = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
bad = Run.from_docs("12", [
_doc(sg="1042", date_="2025-10-05"),
_doc(sg="900", date_="2025-11-30", final="True"),
])
problems = implausible_runs([good, bad])
assert len(problems) == 1
assert problems[0]["beer_run_id"] == "12"
assert problems[0]["fg_points"] == 900
assert "below 1.000" in problems[0]["problem"]
def test_implausible_runs_ignores_good_runs():
from brewing import implausible_runs
good = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert implausible_runs([good]) == []
# ------------------------------------------------------- chart rendering #
def test_charts_do_not_reverse_the_gravity_axis():
"""Gravity must FALL on screen as the beer ferments.
The gravity charts originally set `reverse: true` on the y axis, which
puts the HIGH reading at the bottom of the plot - so the fermentation
curve climbed left to right and read as gravity *rising*. A brewer
reads a fermentation as a descending line, so the axis must not be
reversed. The point-scale run chart had the same bug.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "reverse: true" not in charts_js, (
"a gravity chart is reversing the y axis; the fermentation curve "
"would climb instead of descend"
)
def test_attenuation_chart_truncates_long_names():
"""Long beer names were clipped at the left edge of the plot.
The rotated x-axis labels were cut off ("...x crying out loud"), so the
names are trimmed to a readable length before they are drawn. The
tooltip and the table below carry the full name.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "name.length > 18" in charts_js, (
"the attenuation chart is not trimming long beer names"
)
# ------------------------------------------- suspect runs are not counted #
def _two_runs(repo):
"""One good brew and one with an impossible final gravity.
The bad run mimics the live data: run 12 has a final gravity of 900,
which is below water and makes the run look like a 56-day brew that
reached 18.64% alcohol.
"""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
# 56 days, and a final gravity below 1.000.
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
def _stat_values(body):
"""The figures in the analysis stat tiles, in order.
The tiles are rendered as label/value pairs, so the numbers can be
pulled out without depending on the exact markup.
"""
import re
values = re.findall(r'<div class="stat__value">([^<]+)</div>', body)
return [v.strip() for v in values]
def test_a_suspect_run_is_excluded_from_the_brew_time_average(logged_in, repo):
"""The 56-day bad run must not drag the average brew time up.
This was the review finding: the run was excluded from the attenuation
average but not from the brew-time average, so one bad reading was
poisoning two different statistics.
"""
_two_runs(repo)
response = logged_in.get("/analysis")
assert response.status_code == 200
body = response.get_data(as_text=True)
values = _stat_values(body)
assert values, "the analysis page rendered no stat tiles"
# The stat tiles are: average attenuation, spread, average brew time,
# time to 1.010. The good run took 9 days; with the bad run folded
# in the average would be (9 + 56) / 2 = 32.5 days.
assert "9d" in values, f"expected a 9 day brew time, got {values}"
def test_a_suspect_run_is_excluded_from_the_time_to_1010_average(logged_in, repo):
"""Same for the "time to 1.010" figure."""
_two_runs(repo)
response = logged_in.get("/analysis")
body = response.get_data(as_text=True)
values = _stat_values(body)
assert values
# The good run reached 1.010 in 9 days; the bad one "reached" it in 56.
assert "9d" in values, f"expected 9 days to 1.010, got {values}"
def test_the_suspect_run_is_still_listed_for_review(logged_in, repo):
"""Excluding it from the averages must not hide it.
The run has to stay visible so it can be corrected.
"""
_two_runs(repo)
response = logged_in.get("/analysis")
body = response.get_data(as_text=True)
assert "Readings that need a second look" in body
assert "0.900" in body
def test_a_suspect_run_is_flagged_in_the_api(logged_in, repo):
"""The charts fetch /api/runs, so the flag has to be in the payload."""
_two_runs(repo)
response = logged_in.get("/api/runs")
assert response.status_code == 200
payload = response.get_json()
by_run = {str(r["beer_run_id"]): r for r in payload}
assert by_run["12"]["suspect"] is True
assert by_run["1"]["suspect"] is False
def test_a_suspect_run_is_not_the_strongest_brew_in_the_history(logged_in, repo):
"""The history page showed the bad run as the strongest beer.
An impossible final gravity produced an 18.64% ABV, which made run 12
the "strongest" brew in the history and pulled the average up.
The run's own row still shows its ABV - it is real data, and hiding it
would be worse - but it must not be counted as the strongest brew, and
it must not appear in the "Strongest" figure.
"""
_two_runs(repo)
response = logged_in.get("/history")
assert response.status_code == 200
body = response.get_data(as_text=True)
# The "Strongest" tile shows the good run's 4.76%, not the bad
# run's 18.64%. Find the Strongest tile and read its value.
import re
strongest = re.search(
r"Strongest.*?stat__value[^>]*>([^<]+)<", body, re.DOTALL
)
assert strongest, "the history page has no Strongest figure"
assert "18.64" not in strongest.group(1), (
f"the suspect run was counted as the strongest brew: {strongest.group(1)}"
)
def test_the_run_detail_api_also_flags_a_suspect_run(logged_in, repo):
"""The detail endpoint carries the same flag as the list endpoint.
A consumer of /api/runs/<run_id> must be able to tell an impossible
run from a good one.
"""
_two_runs(repo)
response = logged_in.get("/api/runs/12")
assert response.status_code == 200
assert response.get_json()["suspect"] is True
response = logged_in.get("/api/runs/1")
assert response.status_code == 200
assert response.get_json()["suspect"] is False
def test_the_charts_skip_suspect_runs():
"""The chart script must leave the suspect runs out.
A suspect run's fermentation curve plunges to a gravity below water,
which would drag the median line down and stretch the envelope.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert charts_js.count("!r.suspect") >= 2, (
"the charts must filter out the runs flagged as suspect"
)
# ------------------------------------------------- rising gravity #
def test_attenuation_is_none_when_the_gravity_went_up():
"""A negative attenuation is not a number a brewer can act on.
The guard rejected a final gravity below 1.000 and one implying more
than 100% attenuation, but a run whose gravity ROSE produced a
negative figure that was averaged into the headline number.
"""
run = Run.from_docs("30", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) is None
def test_a_rising_gravity_run_is_reported_as_implausible():
"""It must be listed for review, not just quietly dropped."""
run = Run.from_docs("31", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-20", final="True"),
])
problems = implausible_runs([run])
assert len(problems) == 1
assert problems[0]["beer_run_id"] == "31"
assert "went up" in problems[0]["problem"]
# ------------------------------------------------- undated readings #
def test_the_original_gravity_comes_from_the_first_dated_reading():
"""An undated reading cannot be placed on the timeline.
It used to sort to the front, so it became the OG - which put the OG
after the FG and produced a negative attenuation.
"""
run = Run.from_docs("32", [
_doc(sg="1050", date_="2026-01-01"),
{"_id": "x", "beer_run_id": "32", "beer_type": "Ale", "sg": "1005",
"date": None, "final_reading": "False", "comment": ""},
_doc(sg="1010", date_="2026-01-25", final="True"),
])
assert run.original_gravity() == 1050
assert run.final_gravity() == 1010
assert attenuation_percent(run) is not None
assert attenuation_percent(run) > 0
def test_days_to_target_skips_an_undated_reading():
"""One undated reading must not lose the whole figure.
The function gave up and returned None when the first reading at or
below the target had no date, even when a later reading did.
"""
run = Run.from_docs("33", [
_doc(sg="1050", date_="2026-01-01"),
{"_id": "y", "beer_run_id": "33", "beer_type": "Ale", "sg": "1005",
"date": None, "final_reading": "False", "comment": ""},
_doc(sg="1010", date_="2026-01-25", final="True"),
])
assert days_to_target(run, 1010) == 24
# ------------------------------------------------------- the charts #
def test_the_curve_chart_excludes_active_and_suspect_runs():
"""Only finished, plausible brews belong on the curve.
An active run's curve is a partial fermentation, and the curve is
meant to describe how a completed fermentation behaves.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "!r.active" in charts_js, (
"the curve chart must leave the active runs out"
)
def test_the_curve_band_is_filled_to_the_max_not_the_median():
"""The shaded band has to span min to max.
With one dataset and `fill: "+1"` the band was filled to the NEXT
dataset - which was the median line, so the upper half of the range
was drawn as a hairline but never shaded, and the tooltip disagreed
with the shading.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "envelopeMax" in charts_js, (
"the band needs a separate max dataset to fill down to"
)
assert 'label: "Range max"' in charts_js
def test_the_charts_fetch_the_run_payload_once():
"""/api/runs carries every reading of every run.
Four charts each fetched it independently, so the analysis page made
two identical round-trips of the whole collection.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert charts_js.count('fetch("/api/runs"') == 1, (
"the run payload should be fetched once and shared"
)
assert "function fetchRuns()" in charts_js
# ------------------------------------------------- the chart exclusions #
def test_the_abv_chart_leaves_out_the_suspect_runs():
"""A suspect run's ABV comes from an impossible reading.
Run 12 reads 18.64%, and plotting it stretched the y axis so every
real bar was squashed into the bottom of the plot - while the
"Strongest" tile on the same page correctly showed 8.53%. The chart
and the tile contradicted each other.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
abv_chart = charts_js.split("function drawAbvChart")[1]
abv_chart = abv_chart.split("function drawMonthsChart")[0]
assert "!r.suspect" in abv_chart, (
"the ABV chart must leave the suspect runs out"
)
def test_the_attenuation_chart_leaves_out_the_active_runs():
"""An active run's attenuation is measured against its CURRENT gravity.
A brew that has just started shows a 0% bar - which is the absence of
a result, not a result. The chart sits above the table of finished
brews.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
att_chart = charts_js.split("function drawAttenuationChart")[1]
att_chart = att_chart.split("function drawCurveChart")[0]
assert "!r.active" in att_chart, (
"the attenuation chart must leave the active runs out"
)
def test_a_suspect_active_run_is_still_listed_for_review(logged_in, repo):
"""A bad reading in an active run must not make the run vanish.
The API flags suspect runs over EVERY run, so the charts leave them
out. If the analysis page only looked at the finished ones, an
active run with a bad reading would disappear with no explanation
anywhere.
"""
seed_readings(repo, [
("1", "Finished ale", "1050", "2026-01-01", False, ""),
("1", "Finished ale", "1010", "2026-01-10", True, ""),
# An ACTIVE run whose latest reading is below water.
("20", "Active brew", "1042", "2026-02-01", False, ""),
("20", "Active brew", "0900", "2026-02-10", False, "typo"),
])
response = logged_in.get("/analysis")
body = response.get_data(as_text=True)
assert "Readings that need a second look" in body
assert "0.900" in body
def test_the_history_page_says_how_many_brews_the_abv_figure_is_from(logged_in, repo):
"""The tile must not quietly average fewer brews than it claims."""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
response = logged_in.get("/history")
body = response.get_data(as_text=True)
assert "across 1 of 2 finished brews" in body
def test_the_analysis_prose_counts_the_brews_that_contributed(logged_in, repo):
"""The prose must not claim more brews than actually contributed.
The prose is rendered from the attenuation figures, so it counts the
brews that produced one - not the brews that were merely counted.
"""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("2", "Good pale", "1042", "2026-02-01", False, ""),
("2", "Good pale", "1012", "2026-02-10", True, ""),
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
response = logged_in.get("/analysis")
body = " ".join(response.get_data(as_text=True).split())
# Three finished brews, two of which contributed an attenuation.
assert "Across 2 of 3 finished brews" in body
def test_the_run_detail_api_carries_the_same_flags(logged_in, repo):
"""A consumer of the detail endpoint can tell an impossible run from a
good one, and an active run from a finished one."""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
("20", "Active brew", "1042", "2026-02-01", False, ""),
])
finished = logged_in.get("/api/runs/1").get_json()
assert finished["suspect"] is False
assert finished["active"] is False
active = logged_in.get("/api/runs/20").get_json()
assert active["suspect"] is False
assert active["active"] is True
+435 -12
View File
@@ -7,17 +7,6 @@ import pytest
from conftest import seed_readings
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
# ------------------------------------------------------------------ public #
def test_healthz_is_public(client):
@@ -170,6 +159,15 @@ def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
# ----------------------------------------------------------------- misc #
def test_the_chart_library_is_loaded_exactly_once(logged_in):
"""Regression: the chart script was included both in the base template
and again by each page, so every chart was drawn twice."""
for path in ("/brews", "/active", "/history"):
body = logged_in.get(path).data.decode()
assert body.count("js/charts.js") == 1, path
assert body.count("chart.umd.js") == 1, path
def test_static_assets_are_served(client):
for path in ("/static/js/app.js", "/static/css/app.css",
"/static/img/logo.svg"):
@@ -188,11 +186,16 @@ def test_logout_clears_the_session(logged_in):
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
"""A reading outside the range is SAVED with a warning.
Rejecting it would lose the brewer's actual observation, and seeing it
recorded is how they spot and correct a typo.
"""
response = logged_in.post("/brews", data={
"beer_run_id": "12",
"beer_run_type": "Odd",
"Date": "2026-05-01",
"sg": "950",
"sg": "1250",
}, follow_redirects=True)
assert response.status_code == 200
assert b"outside the usual hydrometer range" in response.data
@@ -200,6 +203,25 @@ def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
def test_a_reading_below_water_says_it_is_always_a_typo(logged_in, repo):
"""A gravity below 1.000 cannot be real, so the warning says so.
This is the run 12 case: a 1.012 reading entered as "012". The
reading is still saved, but the message names the likely cause.
"""
response = logged_in.post("/brews", data={
"beer_run_id": "13",
"beer_run_type": "Typo",
"Date": "2026-05-01",
"sg": "900",
}, follow_redirects=True)
assert response.status_code == 200
assert b"below 1.000" in response.data
assert b"typo" in response.data
# Still saved, so the mistake is visible and can be corrected.
assert repo.readings.find_one({"beer_run_id": "13"}) is not None
# ------------------------------------------------------- security: passwords #
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
@@ -303,3 +325,404 @@ def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
importlib.reload(app_factory)
# ------------------------------------------------- active beers and metadata #
def test_active_page_shows_only_unfinished_runs(logged_in, repo):
seed_readings(repo, [
("1", "Finished Beer", "1050", "2026-01-01", False, ""),
("1", "Finished Beer", "1010", "2026-01-20", True, ""),
("2", "Still Brewing", "1045", "2026-02-01", False, ""),
])
response = logged_in.get("/active")
assert response.status_code == 200
body = response.data.decode()
assert "Still Brewing" in body
assert "Finished Beer" not in body
def test_active_page_shows_the_tap_number(logged_in, repo):
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
repo.upsert_run_metadata("2", tap_number=4)
body = logged_in.get("/active").data.decode()
assert "Tap 4" in body
def test_active_page_shows_the_projected_alcohol(logged_in, repo):
"""With two readings the first is the OG, so the estimate is meaningful."""
seed_readings(repo, [
("2", "Still Brewing", "1050", "2026-02-01", False, ""),
("2", "Still Brewing", "1020", "2026-02-10", False, ""),
])
body = logged_in.get("/active").data.decode()
# 30 point drop = 3.94%
assert "3.94%" in body
def test_active_page_shows_a_dash_when_no_og_is_known(logged_in, repo):
"""A single reading with no recorded OG has no reliable estimate."""
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
body = logged_in.get("/active").data.decode()
assert "needs 2nd reading" in body
def test_active_page_shows_the_latest_comment(logged_in, repo):
seed_readings(repo, [
("2", "Still Brewing", "1050", "2026-02-01", False, "Hops in"),
("2", "Still Brewing", "1030", "2026-02-10", False, "Almost there"),
])
body = logged_in.get("/active").data.decode()
assert "Almost there" in body
def test_run_metadata_is_saved_and_surfaced(logged_in, repo):
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
logged_in.post("/runs/8", data={
"name": "Canonical Name",
"tap_number": "3",
"og_points": "1050",
"notes": "Starter was a 50F mess.",
}, follow_redirects=True)
meta = repo.run_metadata("8")
assert meta["name"] == "Canonical Name"
assert meta["tap_number"] == 3
assert meta["og_points"] == 1050
assert meta["notes"] == "Starter was a 50F mess."
# The canonical name must appear on the brew-process page and the active page.
assert "Canonical Name" in logged_in.get("/brews").data.decode()
assert "Canonical Name" in logged_in.get("/active").data.decode()
def test_run_metadata_can_be_cleared(logged_in, repo):
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
repo.upsert_run_metadata("8", name="Set Name", tap_number=2)
logged_in.post("/runs/8", data={"name": "", "tap_number": "", "og_points": "", "notes": ""},
follow_redirects=True)
meta = repo.run_metadata("8")
# The name was cleared, so it falls back to the reading-derived name.
assert not meta.get("name")
assert meta.get("tap_number") is None
def test_the_stored_name_overrides_the_drifting_reading_name(logged_in, repo):
seed_readings(repo, [
("9", "Old Name", "1050", "2026-03-01", False, ""),
("9", "Newer Name", "1040", "2026-03-10", False, ""),
])
repo.upsert_run_metadata("9", name="The Name I Want")
body = logged_in.get("/brews").data.decode()
assert "The Name I Want" in body
def test_next_free_tap_number_skips_the_ones_in_use(repo):
assert repo.next_free_tap_number() == 1
repo.upsert_run_metadata("1", tap_number=1)
assert repo.next_free_tap_number() == 2
repo.upsert_run_metadata("2", tap_number=2)
assert repo.next_free_tap_number() == 3
# A gap is filled before extending the range.
repo.upsert_run_metadata("3", tap_number=4)
assert repo.next_free_tap_number() == 3
def test_history_page_shows_completed_brews(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
])
response = logged_in.get("/history")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "5.25%" in body # standard ABV for a 40 point drop
assert "Legacy" in body # the comparison table is present
def test_history_page_flags_an_implausible_reading(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "900", "2026-01-20", True, "typo?"),
])
body = logged_in.get("/history").data.decode()
assert "worth a second look" in body
assert "typo?" in body
def test_partial_metadata_update_leaves_other_fields_alone(repo):
"""The backfill uses this: it must not wipe a tap number the operator
has already set."""
repo.upsert_run_metadata("1", name="A Name", tap_number=3, notes="Some notes")
repo.upsert_run_metadata_partial("1", name="A Better Name")
meta = repo.run_metadata("1")
assert meta["name"] == "A Better Name"
assert meta["tap_number"] == 3
assert meta["notes"] == "Some notes"
def test_partial_metadata_update_ignores_none(repo):
repo.upsert_run_metadata("1", name="A Name", tap_number=3)
repo.upsert_run_metadata_partial("1", name=None, tap_number=None)
meta = repo.run_metadata("1")
assert meta["name"] == "A Name"
assert meta["tap_number"] == 3
# ---------------------------------------------------------- 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"]
# ------------------------------------------------------------- deep analysis #
def test_the_analysis_page_renders(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
])
response = logged_in.get("/analysis")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Ginger Beer" in body
# The attenuation figures and the summary sentence.
assert "80.0%" in body
assert "attenuation" in body.lower()
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
"""An unfinished brew has no final reading, so it is not comparable."""
seed_readings(repo, [
("1", "Finished", "1050", "2026-01-01", False, ""),
("1", "Finished", "1010", "2026-01-20", True, ""),
("2", "Still Going", "1045", "2026-02-01", False, ""),
])
body = logged_in.get("/analysis").data.decode()
assert "Finished" in body
assert "Still Going" not in body
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
response = logged_in.get("/analysis")
assert response.status_code == 200
assert b"No finished brews" in response.data
def test_the_analysis_page_is_protected(client):
response = client.get("/analysis")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
"""The charts fetch the curve data from the API."""
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
])
payload = logged_in.get("/api/runs").get_json()
entry = payload[0]
assert entry["attenuation"] == 80.0
assert len(entry["curve"]) == 2
assert entry["curve"][0]["progress"] == 0.0
assert entry["curve"][1]["progress"] == 1.0
def test_the_pages_are_not_cached(client):
"""The HTML pages must not be cached by the browser.
A phone held on to a cached copy of the brewing page and kept
showing the old inline advice after the button and the pop-out had
been deployed, so the change was invisible until the cache was
cleared by hand.
"""
response = client.get("/login")
assert "no-store" in response.headers.get("Cache-Control", "")
File diff suppressed because it is too large Load Diff
+68 -3
View File
@@ -184,17 +184,36 @@ def test_run_is_active_until_a_final_reading_exists():
assert final_reading(finished.readings) is not None
def test_run_display_name_prefers_the_most_used_label():
"""The stored beer_type drifts across readings; the most-used one wins."""
def test_run_display_name_prefers_the_name_on_the_final_reading():
"""The stored beer_type drifts across readings. The name on the final
reading is the one the operator settled on."""
run = Run.from_docs("4", [
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
])
# All three are unique, so the most recent wins.
assert run.display_name() == "Nocturna Christmas beer"
def test_run_display_name_falls_back_to_the_most_used_label():
"""Without a final reading, the most-used name wins, breaking ties
towards the most recent."""
run = Run.from_docs("5", [
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
])
# The final reading's name wins.
assert run.display_name() == "Going Nowhere fast"
run2 = Run.from_docs("6", [
_doc(date="2024-12-03", beer_type="Balter"),
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
_doc(date="2024-12-10", beer_type="Balter"),
])
# No final reading; "Balter" was used twice.
assert run2.display_name() == "Balter"
def test_run_display_name_falls_back_to_the_run_number():
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
assert run.display_name() == "Run 7"
@@ -305,3 +324,49 @@ def test_run_summary_handles_an_empty_run():
assert summary["abv"] is None
assert summary["reading_count"] == 0
assert summary["name"] == "Run 99"
def test_point_scale_is_the_digits_after_the_decimal():
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
reading and must not be labelled as a point count."""
run = Run.from_docs("1", [
_doc(date="2026-01-01", sg="1039"),
])
summary = run_summary(run)
assert summary["min_sg_decimal"] == "1.039"
assert summary["min_sg_point_scale"] == "39"
def test_point_scale_for_a_round_reading():
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
summary = run_summary(run)
assert summary["min_sg_point_scale"] == "10"
assert summary["min_sg_decimal"] == "1.010"
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