Skip to content

fix(i18n): translate empty-value labels in reports (v1.39.1) - #74

Merged
iscarelli merged 1 commit into
mainfrom
fix/i18n-empty-fallbacks
Aug 7, 2026
Merged

fix(i18n): translate empty-value labels in reports (v1.39.1)#74
iscarelli merged 1 commit into
mainfrom
fix/i18n-empty-fallbacks

Conversation

@iscarelli

Copy link
Copy Markdown
Owner

The bug

Three empty-value labels in the reports were shown in Portuguese to every user, including those browsing in English or Spanish:

Where Label leaked
Consumption History → by material (sem material)
Consumption History → by brand (sem marca)
Stock by Location (sem local)

(sem marca) / (sem material) shipped in v1.39.0; (sem local) had been leaking for much longer.

Why they escaped _()

Not an oversight at the call site — they were born in the wrong layer. database.py stamped the finished label straight into the data:

COALESCE(NULLIF(s.location,''), '(sem local)')   # report_by_location()
_breakdown("brand", "(sem marca)")               # consumption_report()

Translation in this project is a string lookup performed in the template. A label that arrives already rendered has nothing left to look up, so it passes through untouched no matter what language is selected.

The fix moves the decision to the layer that owns presentation. The data layer returns an empty string; the template picks the label.

Why _() wraps the literal and never the data

The tempting one-liner, {{ _(row.name) }}, is itself a bug — and avoiding it is the whole point of this PR.

Brand, material and location are free text typed by the user. translations.py is a plain dict keyed by Portuguese source strings, so any user value that happens to collide with a key gets silently rewritten. A real brand named Preço would render as "Price" in English. There is no guard against this, because a lookup table cannot tell a label from a coincidence.

So _() may only wrap the literal, and only on the branch where the value is empty:

{{ r.name if r.name else _('(sem marca)') }}

This is also why the _()-over-a-variable calls in templates/reports/stats.html:12 and templates/spools/list.html:10 are left alone: those translate the colour family, a closed vocabulary produced by database.classify_color (Preto/Branco/Cinza/…), not user input. The rule is not "never _() a variable" — it is "never _() free user data".

Tests

Both halves are pinned, so neither bug can come back quietly:

  • the label is translated in EN and ES on both reports;
  • test_real_location_matching_a_translation_key_is_never_translated and its brand twin create a real value that collides with a translation key (Tudo → "All time", Preço → "Price") and assert it renders unchanged.

I verified these tests are not blind by reintroducing both bugs in the template — 3 tests fail, then pass again once reverted.

The HTTP assertions anchor on text unique to the target page body (Consumed (kg), Stock by Location). This matters here: the bootstrap admin carries must_change_password=1, so every route silently 302s to /account/password, which renders the nav and returns 200 — a naive "is the text there?" check would pass against the wrong page.

249 passed (was 239).

Side effect, an improvement

GET /api/stock stopped emitting a Portuguese UI label into machine-read JSON (Home Assistant consumes this). An unset location is now "" instead of "(sem local)". Noted in the changelog since it is visible to integrations. No documented contract pinned the old literal.

Also

  • docs/ARMADILHAS.md gets the trap in the same commit, per project rule, including the sister sweep and the note about which _()-on-variable calls are legitimate.
  • VERSION → 1.39.1 (translation fix = PATCH), with the changelog entry in the same commit.

🤖 Generated with Claude Code

"(sem marca)", "(sem material)" and "(sem local)" were rendered in
Portuguese for every user, including those browsing in English or
Spanish. The cause was where they were born: `database.py` stamped the
finished label into the data itself --
`COALESCE(NULLIF(s.location,''), '(sem local)')` in `report_by_location()`
and the `fallback` argument of `_breakdown()` in `consumption_report()`.
Translation here is a string lookup performed in the template, so a label
that arrives pre-rendered can never be looked up.

The fix moves the decision to the layer that owns presentation: the data
layer now returns an empty string and the template picks the label.

Why `_()` wraps the literal and never the data
----------------------------------------------
The tempting one-line fix, `{{ _(row.name) }}`, is a bug. Brand, material
and location are free text typed by the user, so a real brand called
"Preco" (a key in translations.py) would silently render as "Price" in
English. `_()` may only wrap the literal, and only on the branch where
the value is empty:

    {{ r.name if r.name else _('(sem marca)') }}

Both halves are pinned by tests: one asserts the label is translated, and
`test_real_location_matching_a_translation_key_is_never_translated`
asserts a real value colliding with a translation key comes out
untouched. Reintroducing either bug fails the suite.

The `_()`-over-a-variable calls left in stats.html and spools/list.html
are deliberate and stay: they translate the colour family, a closed
vocabulary produced by `database.classify_color`, not user input.

Side effect, an improvement: `GET /api/stock` no longer emits a
Portuguese UI label into machine-read JSON -- an unset location is now an
empty string.

Tests: 249 passed (was 239).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@iscarelli
iscarelli merged commit f7cc06a into main Aug 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant