refactor(i18n): language-agnostic identifiers and payloads — i18n with English fallback, not a Spanish default #73

Open
opened 2026-09-24 13:13:51 +00:00 by gabogg · 7 comments
Owner

Blocked by: #49, #178, #186

Requested by the maintainer on 2026-09-24: once every [data-veracity] issue is closed, sweep the codebase programmatically for language-specific code. Several of those issues touch the same files (config defaults, trust thresholds, cycle math), so sweeping earlier would conflict with them. Needs triage before work starts.

Principle

The program should be language-agnostic. Identifiers, API payloads and stored data must not encode a language (SPANISH_DAY_NAMES, reason_es/reason_en). Human-readable text comes from i18n, with English as a fallback for a missing key, never Spanish or English as the default. The UI language is a user preference that i18n resolves, not something code assumes.

Evidence from a first scan (master at 42891fa)

Language in identifiers and payloads

  • app/services/analytics_service.py:14: SPANISH_DAY_NAMES = ["Lunes", …, "Domingo"]. It should be day keys resolved by i18n.
  • app/schemas/models.py:409-411 and app/services/door_classifier.py:137-164: per-language fields badge_text (Spanish) / badge_text_en, reason_es / reason_en. The API ships both languages instead of a key.
  • app/static/js/app.js:892-893: the frontend picks badge_text vs badge_text_en and reason_es vs reason_en by currentLang.

Spanish as the default, not a fallback

  • app/static/js/i18n.js:1768: currentLang = localStorage.getItem('hc_lang') || 'es'.
  • app/static/index.html:2, app/static/docs.html:2: <html lang="es"> hardcoded.
  • app/static/js/app.js:264, :371, :382: inline currentLang === 'es' ? '…' : '…' strings that bypass i18n.

User-facing Spanish text built in the backend (counts of Spanish string literals per file): occupancy_service.py 13, door_classifier.py 11, door_service.py 6, analytics_service.py 3, schemas/models.py 3, access_cycle_aggregator.py 2, database.py 2. Examples:

  • "Salida: Botón de Apertura / Sensor" (schemas/models.py:135, access_cycle_aggregator.py:40)
  • "Cierre no justificado por HikCentral (Sincronización)" (access_cycle_aggregator.py:168)
  • "Ajuste manual de calibración por administrador." (analytics_service.py:408, 421)
  • "Cámara {code}", "Últimas 24 Horas" (occupancy_service.py)
  • seed schedule day names "Miércoles", "Sábado" (database.py:229, 232)

Some of these are persisted (calibration log reason, door classifier reasons), so the stored data is itself in Spanish.

Scope (to be settled in triage)

  1. A repeatable scan (script or test) that flags language-specific identifiers (*_es, *_en, SPANISH_*, ENGLISH_*, …) and non-ASCII string literals in backend code outside i18n, so regressions are caught in CI.
  2. Replace per-language fields and constants with keys; resolve text through i18n on the client (or a backend i18n layer for exports/CSV).
  3. Make i18n resolve the user's language, with English as the fallback for missing keys; no hardcoded 'es' default.
  4. Decide how to handle persisted Spanish text: store codes/keys going forward, and migrate or leave legacy rows.

Open questions for triage

  • Is the backend ever the renderer (CSV/JSON exports, log messages, CLI output)? If so, it needs its own i18n layer, or exports stay keyed.
  • What is the initial language when the user has no stored preference: browser Accept-Language, a per-deployment setting, or English?
  • Does jornada → business cycle (#49) fold into this sweep?
  • Log messages and developer-facing text: in scope, or English-only by convention?

Related: #49.

Triage decisions (2026-09-24)

  • The earlier blockers are closed; now blocked only by #49 (canonical "business cycle"), which goes first so the sweep doesn't re-flag jornada.
  • Initial language: navigator.language when a translation exists, otherwise English. No per-deployment setting; no hardcoded 'es'. English is the fallback for missing keys.
  • Backend output: exports (CSV/JSON) use stable codes and English headers; logs and CLI are English by convention and out of the scan. Only the browser translates.
  • Persisted text: new rows store a code plus parameters; legacy Spanish rows stay as they are and are shown raw when no code exists (no rewrite of audit history, per #34).
Blocked by: #49, #178, #186 > Requested by the maintainer on 2026-09-24: once every `[data-veracity]` issue is closed, sweep the codebase **programmatically** for language-specific code. Several of those issues touch the same files (config defaults, trust thresholds, cycle math), so sweeping earlier would conflict with them. **Needs triage** before work starts. ## Principle The program should be **language-agnostic**. Identifiers, API payloads and stored data must not encode a language (`SPANISH_DAY_NAMES`, `reason_es`/`reason_en`). Human-readable text comes from **i18n**, with **English as a fallback** for a missing key, never Spanish or English as the *default*. The UI language is a user preference that i18n resolves, not something code assumes. ## Evidence from a first scan (`master` at `42891fa`) **Language in identifiers and payloads** - `app/services/analytics_service.py:14`: `SPANISH_DAY_NAMES = ["Lunes", …, "Domingo"]`. It should be day keys resolved by i18n. - `app/schemas/models.py:409-411` and `app/services/door_classifier.py:137-164`: per-language fields `badge_text` (Spanish) / `badge_text_en`, `reason_es` / `reason_en`. The API ships both languages instead of a key. - `app/static/js/app.js:892-893`: the frontend picks `badge_text` vs `badge_text_en` and `reason_es` vs `reason_en` by `currentLang`. **Spanish as the default, not a fallback** - `app/static/js/i18n.js:1768`: `currentLang = localStorage.getItem('hc_lang') || 'es'`. - `app/static/index.html:2`, `app/static/docs.html:2`: `<html lang="es">` hardcoded. - `app/static/js/app.js:264`, `:371`, `:382`: inline `currentLang === 'es' ? '…' : '…'` strings that bypass i18n. **User-facing Spanish text built in the backend** (counts of Spanish string literals per file): `occupancy_service.py` 13, `door_classifier.py` 11, `door_service.py` 6, `analytics_service.py` 3, `schemas/models.py` 3, `access_cycle_aggregator.py` 2, `database.py` 2. Examples: - `"Salida: Botón de Apertura / Sensor"` (`schemas/models.py:135`, `access_cycle_aggregator.py:40`) - `"Cierre no justificado por HikCentral (Sincronización)"` (`access_cycle_aggregator.py:168`) - `"Ajuste manual de calibración por administrador."` (`analytics_service.py:408, 421`) - `"Cámara {code}"`, `"Últimas 24 Horas"` (`occupancy_service.py`) - seed schedule day names `"Miércoles"`, `"Sábado"` (`database.py:229, 232`) Some of these are **persisted** (calibration log `reason`, door classifier reasons), so the stored data is itself in Spanish. ## Scope (to be settled in triage) 1. A **repeatable scan** (script or test) that flags language-specific identifiers (`*_es`, `*_en`, `SPANISH_*`, `ENGLISH_*`, …) and non-ASCII string literals in backend code outside i18n, so regressions are caught in CI. 2. Replace per-language fields and constants with **keys**; resolve text through i18n on the client (or a backend i18n layer for exports/CSV). 3. Make i18n resolve the **user's** language, with **English as the fallback** for missing keys; no hardcoded `'es'` default. 4. Decide how to handle **persisted Spanish text**: store codes/keys going forward, and migrate or leave legacy rows. ## Open questions for triage - Is the backend ever the renderer (CSV/JSON exports, log messages, CLI output)? If so, it needs its own i18n layer, or exports stay keyed. - What is the initial language when the user has no stored preference: browser `Accept-Language`, a per-deployment setting, or English? - Does `jornada` → *business cycle* (#49) fold into this sweep? - Log messages and developer-facing text: in scope, or English-only by convention? Related: #49. ## Triage decisions (2026-09-24) - The earlier blockers are closed; now blocked only by #49 (canonical "business cycle"), which goes first so the sweep doesn't re-flag `jornada`. - **Initial language:** `navigator.language` when a translation exists, otherwise English. No per-deployment setting; no hardcoded `'es'`. English is the fallback for missing keys. - **Backend output:** exports (CSV/JSON) use stable codes and English headers; logs and CLI are English by convention and out of the scan. Only the browser translates. - **Persisted text:** new rows store a code plus parameters; legacy Spanish rows stay as they are and are shown raw when no code exists (no rewrite of audit history, per #34).
Author
Owner

Additions from the round-3 reviews of the [data-veracity] PRs (2026-09-24):

  • #68: the new English API message "No passenger flow readings returned" in the passenger flow sync.
  • #70: the Spanish-only fallback 'SIN CALIBRAR' in app.js (maturity badge); dwell_kpi_label built on the server as f"{dwell_mins:g} min · …", display text and unit that i18n can't reach.
  • #71: the new DATA TRUST / CYCLE COMPLETENESS tiles and the CALIBRATION AND TRUST THRESHOLDS panel (with its 12 field labels) in index.html are hardcoded English with no data-i18n, unlike the surrounding analytics text.
**Additions from the round-3 reviews of the `[data-veracity]` PRs (2026-09-24):** - **#68:** the new English API message `"No passenger flow readings returned"` in the passenger flow sync. - **#70:** the Spanish-only fallback `'SIN CALIBRAR'` in `app.js` (maturity badge); `dwell_kpi_label` built on the server as `f"{dwell_mins:g} min · …"`, display text and unit that i18n can't reach. - **#71:** the new `DATA TRUST` / `CYCLE COMPLETENESS` tiles and the `CALIBRATION AND TRUST THRESHOLDS` panel (with its 12 field labels) in `index.html` are hardcoded English with no `data-i18n`, unlike the surrounding analytics text.
Author
Owner

Follow-up from PR #93 review: a card-only cycle can retain the Spanish button/sensor summaryLabel after async person-name resolution. The cycle has the resolved personName and CREDENTIAL trigger, but the command-deck activity row renders summaryLabel. When replacing localized payload text with language-agnostic identifiers in #73, derive that label from the final trigger/name in the active UI language. This also covers English and Spanish verification for the cardholder-name flow.

Follow-up from PR #93 review: a card-only cycle can retain the Spanish button/sensor `summaryLabel` after async person-name resolution. The cycle has the resolved `personName` and CREDENTIAL trigger, but the command-deck activity row renders `summaryLabel`. When replacing localized payload text with language-agnostic identifiers in #73, derive that label from the final trigger/name in the active UI language. This also covers English and Spanish verification for the cardholder-name flow.
Author
Owner

Scope added from #184 (maintainer triage, 2026-10-02). These two items from #175's second review now belong here:

  • #184 item 6: the backend stores and shows Spanish text in every UI language.
    • A nameless exception is saved as "Excepción de horario", and schedule_label appends "(Cerrado)" (occupancy_service.py).
    • app.js shows that label verbatim, so the English card reads "Schedule Exception: Excepción de horario (Cerrado)".
    • Acceptance: the backend returns language-neutral data (a null name, plus a closed flag or code) and the frontend localizes it. Existing stored rows keep working.
  • #184 item 4: the fallback name is duplicated (occupancy_repository.py, occupancy_service.py). It goes away with item 6.

Related decision on #184 item 7: an exception name is null or non-empty, never "". #178 also replaces its new "Cerrado" in holiday_hours with a closed flag, so it doesn't add to this.

🤖 Generated with Claude Code

Scope added from #184 (maintainer triage, 2026-10-02). These two items from #175's second review now belong here: - **#184 item 6: the backend stores and shows Spanish text in every UI language.** - A nameless exception is saved as `"Excepción de horario"`, and `schedule_label` appends `"(Cerrado)"` (`occupancy_service.py`). - `app.js` shows that label verbatim, so the English card reads "Schedule Exception: Excepción de horario (Cerrado)". - *Acceptance:* the backend returns language-neutral data (a null name, plus a closed flag or code) and the frontend localizes it. Existing stored rows keep working. - **#184 item 4: the fallback name is duplicated** (`occupancy_repository.py`, `occupancy_service.py`). It goes away with item 6. Related decision on #184 item 7: an exception name is `null` or non-empty, never `""`. #178 also replaces its new `"Cerrado"` in `holiday_hours` with a `closed` flag, so it doesn't add to this. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Sequencing update (2026-10-03)

Marked blocked. Its blockers are now #49, #178 and #186:

  • #49 ("business cycle", with jornada moving into the es i18n layer) is still open. This sweep must not re-flag jornada; that was the original blocker.
  • #178 (holiday and event context, in its 4th review pass) and #186 (schedule follow-ups, in progress) are rewriting the code this issue changes: the schedule-label and exception-name construction (schedule_label, the "Excepción de horario" fallback, "(Cerrado)"), and the holiday payloads. Sweeping those files in parallel would cause conflicts and churn.

When unblocked: the scan evidence in the body is from 2026-09-24 (42891fa). Re-run the scan on current master before starting, because many line numbers and some items have changed. The triage decisions in the body still hold:

  • initial language from navigator.language, with an English fallback;
  • exports use codes and English headers;
  • logs and CLI stay English;
  • new persisted rows store a code plus parameters, and legacy rows stay as they are.

Scope note: this issue now spans doors, occupancy, statistics, schedule exceptions and the frontend default language. The agent should open a milestone ("Language-agnostic payloads"), with one PR per area, rather than one giant PR.

## Sequencing update (2026-10-03) Marked `blocked`. Its blockers are now **#49, #178 and #186**: - **#49** ("business cycle", with `jornada` moving into the es i18n layer) is still open. This sweep must not re-flag `jornada`; that was the original blocker. - **#178** (holiday and event context, in its 4th review pass) and **#186** (schedule follow-ups, in progress) are rewriting the code this issue changes: the schedule-label and exception-name construction (`schedule_label`, the `"Excepción de horario"` fallback, `"(Cerrado)"`), and the holiday payloads. Sweeping those files in parallel would cause conflicts and churn. **When unblocked:** the scan evidence in the body is from 2026-09-24 (`42891fa`). Re-run the scan on current master before starting, because many line numbers and some items have changed. The triage decisions in the body still hold: - initial language from `navigator.language`, with an English fallback; - exports use codes and English headers; - logs and CLI stay English; - new persisted rows store a code plus parameters, and legacy rows stay as they are. **Scope note:** this issue now spans doors, occupancy, statistics, schedule exceptions and the frontend default language. The agent should open a milestone ("Language-agnostic payloads"), with one PR per area, rather than one giant PR.
Author
Owner

Scope note from PR #178 review pass 4: the default holiday name is a hardcoded literal. "Excepción de horario" appears twice in add_holiday_async (the insert and new_dict), and the holiday backfill migration uses a different one, "Feriado". Fold this into area (c), occupancy and schedule payloads: one stable code, translated in the browser. #244 removes the migration copy.

Scope note from PR #178 review pass 4: the default holiday name is a hardcoded literal. `"Excepción de horario"` appears twice in `add_holiday_async` (the insert and `new_dict`), and the holiday backfill migration uses a different one, `"Feriado"`. Fold this into area (c), occupancy and schedule payloads: one stable code, translated in the browser. #244 removes the migration copy.
Author
Owner

Scope notes from PR #228 review pass 2 (deferred to #73 sweep):

  1. Nameless holidays export an empty name in CSV (Spec P3-3)

    • analytics_service.py (d.holiday_name or "").
    • Prior to #186, nameless holiday rows in CSV exports fell back to "Excepción de horario" or the ISO date. Decision 8.2 established that nameless exceptions use localized defaults on the frontend, leaving the exported value empty (""). Because CSV exports are not localized by the browser, these cells are now blank. Fold into area (c) / language-neutral export formatting.
  2. Backend-only Spanish suffix (Cerrado) in live-card fallback (Spec P3-4)

    • occupancy_service.py (schedule_label).
    • A closed nameless exception renders as "Schedule exception (Cerrado)" in English because (Cerrado) is pre-existing backend text concatenated in schedule_label. Fold into area (c) when replacing localized backend payload strings with language-neutral identifiers/flags.
Scope notes from PR #228 review pass 2 (deferred to #73 sweep): 1. **Nameless holidays export an empty name in CSV** (Spec P3-3) - `analytics_service.py` (`d.holiday_name or ""`). - Prior to #186, nameless holiday rows in CSV exports fell back to "Excepción de horario" or the ISO date. Decision 8.2 established that nameless exceptions use localized defaults on the frontend, leaving the exported value empty (`""`). Because CSV exports are not localized by the browser, these cells are now blank. Fold into area (c) / language-neutral export formatting. 2. **Backend-only Spanish suffix `(Cerrado)` in live-card fallback** (Spec P3-4) - `occupancy_service.py` (`schedule_label`). - A closed nameless exception renders as "Schedule exception (Cerrado)" in English because `(Cerrado)` is pre-existing backend text concatenated in `schedule_label`. Fold into area (c) when replacing localized backend payload strings with language-neutral identifiers/flags.
Author
Owner

Note from the PR #222 pass-3 review (spec P3-5), for the frontend display-name work here:

Since #222, an unnamed schedule exception has exception_name/holiday_name = null everywhere (#184 item 7). The live schedule_label falls back to the weekday name (e.g. "Domingo (Cerrado)"), the live card at app/static/js/app.js:1889 renders "Schedule Exception: " with an empty name, and the holiday list at app.js:2482 shows an empty name. This PR should supply the localized EN/ES fallback ("Unnamed exception" / "Excepción sin nombre") on those paths.

Note from the PR #222 pass-3 review (spec P3-5), for the frontend display-name work here: Since #222, an unnamed schedule exception has `exception_name`/`holiday_name` = null everywhere (#184 item 7). The live `schedule_label` falls back to the weekday name (e.g. "Domingo (Cerrado)"), the live card at `app/static/js/app.js:1889` renders "Schedule Exception: " with an empty name, and the holiday list at `app.js:2482` shows an empty name. This PR should supply the localized EN/ES fallback ("Unnamed exception" / "Excepción sin nombre") on those paths.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
gabogg/hikcentral#73
No description provided.