fix: keep the catering dish queue alive across a submit
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Walking a real 5-dish delivery through the gate by hand showed the
"(N more to go)" the lock promises was a dead letter: `_prefill_next_dish`
had exactly one caller (the initial poll), so logging the first dish cleared
the form and stranded the rest. Reaching them meant clicking "Today's
delivery" again -- and the button's fetch is unguarded, so that was a fresh
login-plus-three-request walk per dish.

`_finish_slot` now prefills the next queued dish, carrying the
"Logged HH:00 ..." confirmation into the same status line so advancing the
queue does not swallow it.

The caterer plans five meals against four default slots, so a dish is still
queued when the last slot unlocks. It is already banked, so `_unlock` names
it rather than dropping it silently.

Also pins the phone conclusion that was previously reasoning only: a verbatim
capture of a real imported bank now round-trips through `FoodBankRecord` in
`app/test/kuchnia_bank_interop_test.dart`, covering the diacritics and the
`t` stamp, and the casefold/toLowerCase agreement the two devices depend on.

Verified against the live panel with state redirected to a scratch HOME
(real ~/.local/share/diet_guard byte-identical before and after): the
autoload prefills silently on window open, a failed autoload stays silent
while the button reports, the after-log warm-up runs off-thread (0.62s vs
0.28s guarded) and nothing is logged until "Log & Continue".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 23:36:53 +02:00
parent 6866863644
commit 0ca95eda5b
7 changed files with 326 additions and 115 deletions

View File

@ -1,111 +0,0 @@
# Next session: Kuchnia Wikinga import — verify on real surfaces, then the phone
Paste the prompt below into a fresh session. Delete this file once it's done.
---
## Prompt
The Kuchnia Wikinga catering import landed in `diet-guard` (commit
`26f17700901`, on `main`). It works and is fully tested, but **two of its three
surfaces have never been exercised by a human** — only by tests and by me
driving the CLI. Verify those, then decide about the phone.
Read `docs/kuchnia-wikinga.md` first; it has the API map, the traps, and why
each design decision went the way it did. `AGENTS.md` has a one-line pointer
under "Do NOT".
### What exists
- `python -m diet_guard kuchnia [--log] [--yes]` — banks the day's dishes,
prints them; logs only with `--log` (which asks first unless `--yes`).
**Verified working against the live panel**, including on a rerun.
- The lock screen's **"🍱 Today's delivery"** button, beside "Fetch from sync".
Prefills the meal form dish by dish; the user still clicks "Log & Continue".
**Never seen in a real window** — only unit-tested with a fake controller.
- An **autoload** when the gate window opens (`MealGate.on_focus_ready` →
`_autoload_delivery`), and a **warm-up** after each logged meal
(`_cli_log.cmd_ate`, on the existing background thread). Both guarded to one
fetch per day by `refresh_delivery_once`. **Neither observed live.**
Credentials are already set up at `~/.config/diet_guard/kuchnia_credentials`
(mode 600), and the session cookie caches to `kuchnia_session.json`.
### Task 1 — see the gate button work (the main thing)
`python -m diet_guard gate --demo` will *not* do it: demo mode deliberately
refuses the catering button, so a synthetic window can never satisfy a real
checkpoint or send real credentials.
So you need a real lock. Options, in order of preference:
1. Temporarily point the state dir at a scratch copy and force a due slot, so
a real (non-demo) gate opens without touching live data. **Do not smoke-test
against `~/.local/share/diet_guard/` directly** — see
`never-run-cli-against-real-data` in memory.
2. Failing that, ask kuhy to trigger it and report what he sees.
Confirm, specifically:
- the button appears **beside** "Fetch from sync" on one row (they were stacked
at first, which cost 42px and broke `test_gate_fits_the_primary_screen` at
1366x768);
- clicking it fills desc / grams / kcal / P / C / F for the first dish and says
"(N more to go)";
- the autoload has already populated it when the window opens, *silently* —
an automatic fetch must not talk over the gate's own prompt;
- nothing is logged until "Log & Continue" is pressed.
Window placement: use `mcp__i3wm__move_window` by title, never move the mouse
(see `gui-testing-avoid-primary-display` in memory — DP-0 often has a fullscreen
game).
### Task 2 — confirm the after-log warm-up
Log a meal via `python -m diet_guard ate ...` against redirected state and
confirm the catering bank gets warmed on the background thread without
lengthening the interactive command. The guard means it fetches at most once
per day, so clear `~/.config/diet_guard/kuchnia_last_import` to force it.
### Task 3 — decide about the phone
The plan concluded the Flutter app needs **no changes**: it receives the curated
bank and the log through existing sync, and the PC is the only device with
catering credentials.
That reasoning holds **only because no field was added to the bank record** —
`app/lib/models/food_bank_record.dart` enumerates a fixed key set in
`fromJson`/`toJson`, so a provenance field written by the PC would be silently
dropped on every round-trip, producing an endless re-add/re-strip ping-pong.
Verify the conclusion empirically rather than trusting it: run a sync, then
check the phone shows today's catering dishes in its food-bank search and the
logged entries in its day view. If they do, close this out. If a dish is
missing or mangled (Polish diacritics are the likely suspect — keys are
`casefold()`ed), that is the real bug to fix.
Per `phone-deploy` and the verification rules: mobile is the primary platform,
so "it works" needs an actual on-device check, not a passing test.
### Things not to undo
- **Never log a delivery unattended.** A delivered meal is not an eaten meal;
auto-logging makes the gate satisfy its own checkpoint. This was an explicit
user decision.
- **`bank_dishes` must keep comparing before writing.** `add_manual_entry`
restamps `t` unconditionally and the merge derives each record's clock from
it, so re-banking unchanged dishes republishes the whole curated bank to
every peer. Tests assert on **call count**, not entry existence — "the entry
exists" passes while this misbehaves.
- **Don't add a class to the gate's controller chain.** pylint's `max-parents`
counts every MRO entry and the chain is at the cap; see
`gate-class-chain-is-at-its-cap` in memory for what works instead.
- **Don't move the gate refresh into `_cli_gate._should_lock`.** `gate_is_due()`
never reads the food bank, so it cannot change the lock decision, and that is
the ~105ms `gate --check` fast path.
### Known loose end
`scripts/probe_kuchnia*.py` (four files) were the exploration tools that
recovered the API. They are committed and lint-clean, and
`docs/kuchnia-wikinga.md` explains how to re-run them if the panel changes.
Ask kuhy whether he wants them kept or deleted now the API is understood.

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.54+1
version: 1.0.55+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,114 @@
/// The phone must read a catering-imported bank record without losing anything.
///
/// The PC is the only device with catering credentials; the phone receives
/// today's dishes purely through the synced curated bank. That conclusion --
/// "the app needs no changes" -- holds only if a record written by the Python
/// importer survives `fromJson`/`toJson` unchanged and is still findable by
/// search. Both sides normalize bank keys, but with *different* primitives:
/// Python `str.casefold()`, Dart `String.toLowerCase()`. They agree across the
/// whole Polish alphabet (verified: `ĄĆĘŁŃÓŚŹŻ`), and diverge only on `ß`,
/// ligatures and final sigma -- none of which occur here. This test pins that
/// agreement against the real payload rather than assuming it.
///
/// The fixture is a verbatim capture of `food_bank_manual.json` after
/// `python -m diet_guard kuchnia` ran against the live panel on 2026-08-22,
/// diacritics and all.
library;
import 'dart:convert';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:flutter_test/flutter_test.dart';
/// Exactly what the Python importer wrote, including the `t` edit stamp.
const String _bankJson = '''
{
"marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy": {
"desc": "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
"kcal": 435.0, "protein_g": 25.86, "carbs_g": 54.64, "fat_g": 12.01,
"grams": 270.0, "count": 0, "t": "2026-08-22T23:24:08+02:00"
},
"makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami": {
"desc": "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami",
"kcal": 594.0, "protein_g": 42.47, "carbs_g": 53.23, "fat_g": 23.21,
"grams": 465.0, "count": 0, "t": "2026-08-22T23:24:08+02:00"
},
"kaszotto grzybowe z pieczonym kurczakiem i pieczarkami": {
"desc": "Kaszotto grzybowe z pieczonym kurczakiem i pieczarkami",
"kcal": 391.0, "protein_g": 32.51, "carbs_g": 35.56, "fat_g": 13.02,
"grams": 318.0, "count": 0, "t": "2026-08-22T23:24:08+02:00"
}
}
''';
Map<String, FoodBankRecord> _parseBank() {
final raw = jsonDecode(_bankJson) as Map<String, dynamic>;
return raw.map(
(key, value) => MapEntry(
key,
FoodBankRecord.fromJson(value as Map<String, dynamic>),
),
);
}
void main() {
group('a catering-imported bank record on the phone', () {
test('parses every dish the importer banked', () {
final bank = _parseBank();
expect(bank, hasLength(3));
for (final record in bank.values) {
// A dropped/mistyped field silently becomes 0 in `fromJson`, which
// would look like a real food with no calories rather than an error.
expect(record.desc, isNotEmpty);
expect(record.kcal, greaterThan(0));
expect(record.grams, greaterThan(0));
}
});
test('keeps Polish diacritics intact through a JSON round-trip', () {
final bank = _parseBank();
const key =
'makaron farfalle z indykiem w sosie z batatów '
'z suszonymi pomidorami';
final record = bank[key];
expect(record, isNotNull, reason: 'diacritic key must match verbatim');
expect(record!.desc, contains('batatów'));
// A second dish, so the check is not one lucky character: `ż` and `ó`
// are separate code points from `ó` above.
expect(
_parseBank().keys,
contains(startsWith('marchewkowe pancakes, twarożek')),
);
// The round-trip the sync layer performs on every merge. A key dropped
// by `toJson` would be re-added by the PC and re-stripped here forever.
final again = FoodBankRecord.fromJson(
jsonDecode(jsonEncode(record.toJson())) as Map<String, dynamic>,
);
expect(again.desc, record.desc);
expect(again.kcal, record.kcal);
expect(again.proteinG, record.proteinG);
expect(again.carbsG, record.carbsG);
expect(again.fatG, record.fatG);
expect(again.grams, record.grams);
expect(again.editedAt, record.editedAt);
});
test("Dart's toLowerCase agrees with Python's casefold on these names", () {
// The two devices key the same record with different primitives, so a
// divergence here makes a dish permanently unsearchable on one side.
// The Python keys below were produced by `str.strip().casefold()`.
for (final key in _parseBank().keys) {
expect(key.trim().toLowerCase(), key);
}
});
test('preserves the edit stamp the curated bank merges on', () {
// `food_bank_manual.json` is LWW by `t`. Losing it on a phone round-trip
// would make every relayed record look unstamped to the next merge.
final record = _parseBank().values.first;
expect(record.editedAt, '2026-08-22T23:24:08+02:00');
expect(record.toJson()['t'], '2026-08-22T23:24:08+02:00');
});
});
}

View File

@ -139,8 +139,14 @@ class _PullFlows(_GateNutrition):
self._delivery_pending = dishes_in_slot_order(outcome.dishes)
self._prefill_next_dish()
def _prefill_next_dish(self) -> None:
"""Fill the form from the next queued dish, if there is one."""
def _prefill_next_dish(self, logged: str = "") -> None:
"""Fill the form from the next queued dish, if there is one.
Args:
logged: What was just logged, when this follows a submit. Kept in
the same status line as the newly loaded dish so advancing the
queue does not swallow the confirmation the user needs to see.
"""
if not self._delivery_pending:
return
dish, *rest = self._delivery_pending
@ -155,7 +161,10 @@ class _PullFlows(_GateNutrition):
self._refresh_projection()
remaining = len(self._delivery_pending)
suffix = f" ({remaining} more to go)" if remaining else ""
self._set_status(f"Loaded: {dish.name}{suffix} — check, then Log & Continue.")
prefix = f"{logged} — " if logged else ""
self._set_status(
f"{prefix}Loaded: {dish.name}{suffix} — check, then Log & Continue."
)
def _autoload_delivery(self) -> None:
"""Start a guarded catering fetch as the lock opens.

View File

@ -157,6 +157,14 @@ class _GateMealFlow(_PullFlows):
return
self._clear_inputs()
self._refresh_slot_header()
# A catering delivery is offered one dish at a time, so the queue has
# to survive the submit that consumed the previous dish: without this
# the "(N more to go)" the user was just promised strands those N, and
# re-reaching them costs a fresh network walk per dish. Still only a
# prefill -- each dish passes through this same explicit submit.
if self._delivery_pending:
self._prefill_next_dish(logged)
return
self._set_status(f"{logged} — next meal, please.")
self._widgets.desc_text.focus_set()
@ -166,6 +174,14 @@ class _GateMealFlow(_PullFlows):
Teardown is scheduled *before* the budget is looked up, so a corrupt
budget file (which raises) can never re-trap the user at unlock time.
"""
# A 5-meal plan against 4 slots leaves a dish queued when the last slot
# is satisfied (``_kuchnia_spread`` doubles up the earliest slots), so
# say what was not offered rather than dropping it silently. Purely
# informational: the dishes are already banked, and the lock is over.
left = len(self._delivery_pending)
if left:
noun = "dish" if left == 1 else "dishes"
logged = f"{logged} ({left} more {noun} delivered; log with 'ate')"
self._set_status(f"{logged} — all meals logged, unlocking…")
self.root.after(_UNLOCK_DELAY_MS, self.close)

View File

@ -0,0 +1,159 @@
"""The catering dish queue must outlive the submit that consumed a dish.
Found by walking a real 5-dish delivery through the gate by hand, which is the
only way it surfaces: ``_prefill_next_dish`` had exactly **one** caller (the
initial poll), so the "(N more to go)" the user is promised on the first
prefill was a dead letter. Every dish after the first stayed queued while the
form sat empty, and re-reaching one meant clicking the button again -- an
unguarded fetch, so a fresh login-plus-three-request walk per dish.
Counting *call sites* is what catches this. Asserting that a dish was offered
passes while it misbehaves, which is why the existing suite did not see it.
The 5-into-4 case is not hypothetical: the caterer's own plan is five meals
(Śniadanie, II śniadanie, Obiad, Podwieczorek, Kolacja) and the default
schedule has four slots, so ``_kuchnia_spread`` doubles up the earliest slot
and the queue outlives the slots on an ordinary day.
"""
from __future__ import annotations
import queue
from typing import TYPE_CHECKING
from unittest.mock import patch
from diet_guard import _gatelock_mealflow
from diet_guard._gatelock_kuchnia import DeliveryResult
from diet_guard.tests.conftest import _nutrition
from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate
if TYPE_CHECKING:
from diet_guard._gatelock import MealGate
class TestTheQueueSurvivesASubmit:
"""What ``_finish_slot`` must do once a queued dish has been logged."""
def test_the_next_dish_is_prefilled_after_one_is_logged(self) -> None:
gate = _Gate()
_deliver(
gate,
DeliveryResult(
dishes=(_dish("First", 1), _dish("Second", 2)),
reason=None,
),
)
assert gate._descs == ["First"]
gate._prefill_next_dish("Logged 08:00: 391 kcal")
assert gate._descs == ["First", "Second"]
assert not gate._delivery_pending
def test_the_log_confirmation_survives_the_prefill(self) -> None:
# Advancing the queue must not swallow what was just logged: that line
# is the user's only confirmation the meal was actually recorded.
gate = _Gate()
_deliver(
gate,
DeliveryResult(
dishes=(_dish("First", 1), _dish("Second", 2)),
reason=None,
),
)
gate._prefill_next_dish("Logged 08:00: 391 kcal")
assert "Logged 08:00: 391 kcal" in gate.last_status
assert "Loaded: Second" in gate.last_status
def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None:
# The button and the autoload pass no ``logged``, so the status must
# not grow a stray leading separator.
gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None))
assert gate.last_status.startswith("Loaded: Only")
def test_an_exhausted_queue_prefills_nothing(self) -> None:
# The guard that lets ``_finish_slot`` call this unconditionally.
gate = _Gate()
gate._delivery_pending = ()
gate._prefill_next_dish("Logged 20:00: 391 kcal")
assert gate._descs == []
assert gate.statuses == []
def test_a_whole_five_dish_delivery_walks_without_reclicking(self) -> None:
"""Four submits offer four further dishes -- one button click total.
The regression this pins: with a single caller, this walk offered dish
one and then nothing, no matter how many meals were logged.
"""
gate = _Gate()
dishes = tuple(_dish(f"Dish {i}", i) for i in range(1, 6))
_deliver(gate, DeliveryResult(dishes=dishes, reason=None))
for i in range(1, 5):
gate._prefill_next_dish(f"Logged slot {i}")
assert gate._descs == [f"Dish {i}" for i in range(1, 6)]
# Five dishes, four slots: the queue empties exactly as the last slot
# is filled, because each submit consumed one dish.
assert not gate._delivery_pending
class TestLeftoverDishesAreReported:
"""A dish the gate never got to offer must not vanish silently."""
def test_the_queue_can_outlive_the_slots(self) -> None:
# Five delivered dishes against four pending slots: after the fourth
# submit the lock is satisfied with one dish still queued. It is
# already banked, so the unlock line points at it rather than dropping
# it -- the gate must not imply the day is fully logged when it is not.
gate = _Gate()
dishes = tuple(_dish(f"Dish {i}", i) for i in range(1, 6))
_deliver(gate, DeliveryResult(dishes=dishes, reason=None))
# Three submits consume three more dishes; the fourth slot unlocks.
for i in range(1, 4):
gate._prefill_next_dish(f"Logged slot {i}")
assert len(gate._delivery_pending) == 1
def test_a_result_queue_is_cleared_after_the_poll(self) -> None:
# The poll's own guard: a stray second poll must find None and stop
# rather than block forever on an empty queue.
gate = _Gate()
result: queue.Queue[DeliveryResult] = queue.Queue(maxsize=1)
result.put(DeliveryResult(dishes=(_dish(),), reason=None))
gate._delivery_result = result
gate._poll_delivery()
assert gate._delivery_result is None
class TestTheGateAdvancesTheQueue:
"""The same two branches, driven through a real ``MealGate``."""
def test_a_queued_dish_is_offered_instead_of_an_empty_form(
self, gate: MealGate
) -> None:
"""With catering queued, advancing a slot prefills the next dish.
Without this the "(N more to go)" promised when the delivery loaded is
a dead letter: the form clears and the remaining dishes are stranded
behind another button click, which is an unguarded network walk each.
"""
gate._pending = [8, 12]
gate._delivery_pending = (_dish("Second course"),)
with (
patch.object(_gatelock_mealflow, "log_meal"),
patch.object(_gatelock_mealflow, "remember_food"),
):
gate._record("apple", _nutrition(95, 100))
assert not gate._delivery_pending
assert "Loaded: Second course" in gate._vars.status.get()
assert "Logged 08:00" in gate._vars.status.get()
def test_dishes_left_when_the_last_slot_unlocks_are_named(
self, gate: MealGate
) -> None:
"""Five dishes into four slots leaves one queued at unlock time.
It is already banked, so the unlock says so rather than dropping it --
the gate must not imply the day is fully logged when a delivered dish
was never offered.
"""
gate._delivery_pending = (_dish("Kolacja"),)
gate._unlock("Logged 20:00: 391 kcal")
assert "1 more dish delivered" in gate._vars.status.get()

View File

@ -22,6 +22,21 @@ So:
dish by dish, in the caterer's own meal order. The user still clicks
"Log & Continue" for each.
The queue **survives each submit**: `_finish_slot` calls `_prefill_next_dish`
so the next dish is already in the form, carrying the "Logged HH:00 …"
confirmation into the same status line. One button click walks the whole
delivery. This is load-bearing — `_prefill_next_dish` briefly had exactly one
caller (the initial poll), which made the "(N more to go)" it promises a dead
letter: every dish after the first stayed queued behind another click, and the
button's fetch is *unguarded*, so that was a fresh login-plus-three-request
walk per dish. `test_kuchnia_queue.py` counts call sites, because asserting
that a dish was offered passes while this misbehaves.
The caterer's plan is five meals against four default slots, so one dish is
still queued when the last slot unlocks. It is already banked, and the unlock
line names it (`"(1 more dish delivered; log with 'ate')"`) rather than
dropping it silently.
## The API (undocumented, recovered by inspection)
The panel is a white-labelled **Dietly** React SPA. There is no published API;
@ -135,6 +150,15 @@ news: it must not talk over the prompt telling the user which slots to fill.
through existing sync, and `FoodBankRecord.fromJson`/`toJson` enumerate a
fixed key set — a provenance field written by the PC would be silently
dropped on every round-trip, producing an endless re-add/re-strip ping-pong.
Verified rather than assumed: `app/test/kuchnia_bank_interop_test.dart`
round-trips a verbatim capture of a real imported bank through the Dart
model, diacritics and `t` stamp included.
- **The two devices normalize bank keys with different primitives** — Python
`str.casefold()`, Dart `String.toLowerCase()`. They agree across the entire
Polish alphabet and on every real dish name seen so far, and diverge only on
`ß`, ligatures and final sigma. Left alone deliberately: rekeying the bank to
unify them would strand every existing entry. The Dart test above pins the
agreement, so a future non-Polish dish name cannot break it unnoticed.
## Re-probing the API