Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
14 KiB
Kuchnia Wikinga catering import
Pulls the day's delivered dishes — with their real per-portion macros — out of
panel.kuchniavikinga.pl and into diet_guard, so a catering meal is one
keypress instead of five retyped numbers.
What it does, and deliberately does not
A delivered meal is not an eaten meal. Dishes land in the curated food bank; entries are only ever written after an explicit confirmation. If the importer logged automatically, the gate would satisfy its own checkpoint from a delivery note, and the log would record what the courier dropped off rather than what was eaten — skipped, shared and binned meals included.
So:
diet-guard kuchniabanks the day's dishes and prints them. Nothing is logged.diet-guard kuchnia --logasks first (--yesskips the prompt for scripting), then fills today's empty slots — the same rule as below.- The lock screen's "🍱 Today's delivery" button prefills the meal form, dish by dish, in the caterer's own meal order. The user still clicks "Log & Continue" for each.
- "🍱 Fill all" takes two clicks: the first lists what would be logged and
relabels it "✓ Confirm (N)", the second logs every dish into its slot —
all of today's, later ones included — skipping any slot that already holds
a meal (
_kuchnia_log.fill_plan, re-checked at the second click).
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; the endpoint map was recovered from its Vite bundles and confirmed against live responses on 2026-08-22. Re-check if the bundle hashes change.
| Base | https://panel.kuchniavikinga.pl/api |
| Login | POST auth/login, form-urlencoded username=…&password=… |
| Session | a SESSION cookie — not a bearer token |
| Headers | company-id: kuchniavikinga (the company name), X-Launcher-Type: BROWSER_PANEL |
The walk is three calls:
company/customer/order/active-ids→[orderId, …], newest first. A renewal overlapping the running order makes it two ids, and the first is the future order — so every id is walked until one has the day's delivery (2026-10-10: reading only[0]made every day look empty).company/customer/order/{orderId}→ the order, which embeds every delivery with its date. No enumeration call needed — butdeliveryMealscarries ids only, no names and no macros.company/general/menus/delivery/{deliveryId}/new→ the actual menu.
Three traps worth knowing before touching _kuchnia_orders.py:
.../deliveries/{id}/detailsdoes not work. It 404s (400 keyed by date), verified against three separate delivery days, despite being the obvious name and the one the bundles pass adeliveryIdto. The menu endpoint above is the one that answers.deliveryIdis opaque, never a date. The date form 400s.- No
XSRF-TOKENcookie is issued, so the CSRF echo the panel's own JavaScript performs is a no-op here. The client skips it when absent rather than sending an empty header.
Macros are per portion
nutrition = {weight, calories, protein, carbohydrate, fat, dietaryFiber, sugar, salt, saturatedFattyAcids}. weight is the portion in grams and the
macros are totals for that portion, which maps straight onto diet_guard's
grams/kcal/protein_g/carbs_g/fat_g.
This was proven, not assumed: 4·protein + 4·carbs + 9·fat reproduces the stated
calories within ~1% on every meal of the captured day, and the day totals
2055 kcal against the plan's declared 2000.
_kuchnia_parse._energy_is_consistent re-runs that check on every dish and
drops any that fails. A per-100 g/per-portion mix-up is invisible in a
payload and would silently skew every logged meal by a factor of several, so
the parser refuses rather than importing a plausible-looking lie.
The same mix-up has a second home: the PC gate's form. Its macro fields are a
reference scaled from the "per" basis to the amount eaten, so the delivery
prefill must go through _apply_reference with _kuchnia_log.dish_nutrition
(basis = the dish's own grams). Writing the macros straight into the fields
left the basis at _clear_inputs's 100 g default and logged grams/100 times
too much while the fields still read correctly (350 g owsianka: 448 shown,
1568 logged; every gate-logged delivery 2026-08-23 to 2026-09-20). The phone's
dishFieldValues has no such basis and is correct as is.
Credentials
Three files under ~/.config/diet_guard/, all mode 600. They are outside
DATA_DIR so they stay off the food-log sync path — but one of them, the
credential itself, does travel between devices.
The password syncs, in plaintext. The phone runs its own importer and
cannot fetch without it, and a phone that has been wiped needs it back without
the user digging out the original. It rides its own document
(diet_guard/sync_merge/_kuchnia.py, devices/<id>/kuchnia.json), not the
budget record — budget.json is written back at default permissions, so a
password inside it would be readable by anything that reads the budget. Nothing
encrypts it, and the rest of the synced state is not encrypted either, so do
not describe it as "encrypted like everything else". This is a deliberate
trade: the alternative was a phone that cannot fetch at all, and the blast
radius is a catering menu and a delivery address.
The session cookie does not sync. It is regenerable from the password, so copying it around would widen exposure and buy nothing.
| file | written by | syncs? |
|---|---|---|
kuchnia_credentials |
the user, by hand | no — a local override, and the bootstrap for the first push |
kuchnia_synced_credential.json |
the sync layer | yes, as its own record |
kuchnia_session.json |
the client, on login | no |
kuchnia_credentials wins over the synced copy when present, so a machine-local
override always works. It is never pushed, though — only used to bootstrap
when no device has published a credential yet. Its mtime is the only edit time
it has, and git checkout, a backup restore or re-running the install line
below all bump it without the credential changing; letting that compete in the
merge let a merely-touched file overwrite a password just typed on the phone.
install -m 600 /dev/null ~/.config/diet_guard/kuchnia_credentials
printf '%s\n%s\n' 'you@example.com' 'your-password' \
> ~/.config/diet_guard/kuchnia_credentials
kuchnia_credentials is written by hand and never by the package, the same
contract as sync_token. kuchnia_session.json caches the session cookie so a
refresh normally skips the login round trip; it is created touch(mode=0o600)
before it holds anything, then atomically replaced — writing and chmod'ing
afterwards would leave a live cookie world-readable for the duration.
Missing or unreadable credentials are not an error at the call site: the fetch returns a reason string and the caller carries on.
Triggers
All event-driven. No new systemd timer — diet-guard-sync.timer was
deliberately deleted and install.sh actively uninstalls it.
| Where | Guarded? | |
|---|---|---|
| CLI | diet-guard kuchnia |
no — an explicit ask always goes and looks |
| Gate | MealGate.on_focus_ready → _autoload_delivery |
yes |
| After a meal | _cli_log.cmd_ate, on the existing background thread |
yes |
| Phone: log screen | LogMealKuchniaMixin.loadTodaysDelivery on initState |
yes |
| Phone: settings | the "Fetch today's delivery" button | no — same rule as the CLI |
The guarded ones go through refresh_delivery_once (Python) or
KuchniaQueueService.refreshOnce (Dart), which skip the walk when today has
already been fetched. Without that the after-log hook would pay a login plus
three requests after every meal. Only a clean fetch records the date, so an
outage is retried.
The marker is device-local on both sides — kuchnia_last_import under
~/.config on the PC, kuchnia_last_import.json in the app's document store
on the phone. It is a rate limit, not shared state: syncing it would let one
device's fetch suppress the other's.
Why the gate hook is in on_focus_ready and not _cli_gate._should_lock:
gate_is_due() reads only the food log and the schedule, never the food bank,
so a catering refresh there could not change the lock decision — while adding a
third-party round trip to the ~105ms gate --check fast path, which opens no
window at all. Hooking it once the window is up puts the spinner inside the
lock instead of in front of it. An automatic fetch is also silent about bad
news: it must not talk over the prompt telling the user which slots to fill.
Things that will bite
-
Re-banking unchanged dishes floods sync.
add_manual_entryrestampstunconditionally and the merge derives each record's clock from it, so a refresh that re-banks everything republishes the whole curated bank to every peer.bank_dishescompares the nutritional fields first. Tests assert on call count — "the entry exists" passes while this misbehaves. -
Imports go to
food_bank_manual.jsononly. The derived bank is rewritten from the log on every meal, so anything written there vanishes. -
Duplicate log suppression reads today's log, not a marker.
log_mealmints a fresh uuid per entry, so a re-import after a lost marker would merge as duplicates on every peer. Matchingdesc+slotagainst the synced log is robust to marker loss and correct across devices. -
requestsis lazy-imported. The gate's not-due tick imports the CLI and must not pay ~78ms for an HTTP stack it never touches. Call sites reach throughsys.modules[__name__]sopatch.objectstill wins. -
The phone has its own importer, and the two must agree exactly. It no longer merely receives the curated bank: it fetches, parses and banks the day's dishes itself, so the feature works with the PC switched off. That makes three things load-bearing across two languages — which dishes are dropped, which slot each lands on, and the exact JSON a banked record encodes to. A disagreement on any of them means each device re-adds what the other dropped;
add_manual_entryrestampstunconditionally, so the whole curated bank republishes to every peer on every refresh. A slot mismatch is worse: a checkpoint one device offers and the other does not can never be satisfied.Gated by one shared fixture,
tests/fixtures/kuchnia_day.json, asserted bydiet_guard/tests/test_kuchnia_parity.pyandapp/test/kuchnia_parity_test.dart. Two suites written independently from the same prose is not a gate; one input with one expected result is. Regenerate it withscripts/build_kuchnia_fixture.py, and re-read the diff before committing — the script blesses whatever Python currently does.Three traps the fixture pins, each of which passed review once:
_ENERGY_TOLERANCEis 0.35, not the ~1% the prose above quotes for the captured day;jsonEncode(435)emits435where Python emits435.0, so every Dart macro is forced todouble; and Dart'sList.sortis unstable where Python'ssortedis stable, so the Dart comparator carries the payload index as a final tiebreak. -
Bank records still round-trip losslessly through the Dart model.
FoodBankRecord.fromJson/toJsonenumerate 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.app/test/kuchnia_bank_interop_test.dartround-trips a verbatim capture of a real imported bank through the Dart model, diacritics andtstamp included. -
The two devices normalize bank keys with different primitives — Python
str.casefold(), DartString.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.
The phone runs the same import
app/ carries a Dart mirror of the whole walk, so the feature works with the
PC switched off — including the cross-language parity gate that keeps the two
from drifting. See kuchnia-wikinga-phone.md.
Re-probing the API
scripts/probe_kuchnia.py (login, order walk) and
scripts/probe_kuchnia_details.py (which URL form serves a day's meals) dump
redacted JSON captures. Run the first, then the second. They prompt once and
cache the session, and credential/cookie values never reach the dump.