Skip to content

Per-user language selection - #79

Merged
sebo-b merged 9 commits into
mainfrom
feature/language-selection
Jul 7, 2026
Merged

Per-user language selection#79
sebo-b merged 9 commits into
mainfrom
feature/language-selection

Conversation

@sebo-b

@sebo-b sebo-b commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Implements per-user UI language selection per PLAN_language_selection.md, refined through the _CODE_REVIEW.md fixes.

What

  • Move the active UI language from a single deployment-wide file (LANGUAGE_FILE) to a per-user choice carried across login/logout via a warp_lang cookie.
  • New config: LANGUAGES (JSON array) + DEFAULT_LANGUAGE; LANGUAGE_FILE removed (breaking env-var change, noted in CONFIGURATION.md).
  • Precedence: logged-in → prefs > cookie > default; login page → cookie > default.
  • Centralized cookie↔prefs sync in /xhr/bootstrap (covers every auth backend).
  • Prefs modal Language row (Materialize M.Dropdown with flags); no "Default" entry — a NULL pref shows the deployment default applied, not selectable.
  • Login top-bar hand-rolled flag dropdown (no Materialize JS in the public bundle).
  • iCal feed + action pages are owner-bound (user_prefs.language, NULL → DEFAULT_LANGUAGE); the warp_lang cookie is never read for ical.
  • Schema migration 18 → 19 (user_prefs.language); flag SVGs (lipis/flag-icons, MIT, attributed in README.md).
  • i18n/locale updates; check_i18n.py clean.

Tests

  • New pytest: test_config_languages.py, test_i18n_resolve.py, test_prefs_language.py (DB-backed).
  • New e2e: tests/settings/language.spec.ts.
  • tests/settings/ical-language.spec.ts retained via a back-compat debug/set_language repurposing.

Latest commit — e2e flake fixes (6345be4)

Three language.spec.ts tests failed under full-suite load (passed in isolation) — all test-side; the app is correct (POST /xhr/prefs language=de → 200 → DB user1|de verified):

  • Open the prefs link by href="#pref_modal", not English text (this spec switches languages).
  • Register waitForResponse(/xhr/prefs GET) before page.goto so it catches this boot's GET under load.
  • New savePrefsAndReload: await the POST response (.ok()) first, then the modal leaving the DOM, before waitForViewReady — a pre-armed waitForURL let the reload abort the POST (status -1, de never persisted).
  • Seed the stale warp_lang cookie on getRuntimeInfo().baseURL, not http://localhost (same trap plan-management.spec.ts documents for warp_theme).

Full tests/settings/ passes 90/90 across repeats; full e2e suite 538/538 (0 flaky).

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>

sebo-b and others added 9 commits July 6, 2026 16:58
Move the UI language from a single deployment-wide file (LANGUAGE_FILE) to a
per-user choice with a warp_lang cookie carrying it across login/logout.

- Config: LANGUAGES (JSON array) + DEFAULT_LANGUAGE; LANGUAGE_FILE removed;
  startup validation (i18n.init_app) aborts on bad config.
- warp/i18n.py: locale metadata lives in each i18n/<code>.json (name/flag
  top-level fields); pure resolve(cookie, pref, default) — prefs win for
  logged-in, cookie for the login page, default fallback; always reads prefs
  at render so a stale cookie can't shadow a user pref (shared-device).
- Bootstrap sync (centralized, auth-backend-agnostic): prefs non-NULL resets
  the cookie; NULL pref + valid cookie upserts into prefs (INSERT(login,
  language) on conflict — plain UPDATE no-ops for prefs-less users); invalid
  cookie deleted; NULL+no-cookie -> default, no cookie set.
- Prefs: language column (NULL = Default/deployment fallback); schema is
  type-only with a runtime in-LANGUAGES check (400); wire value for Default is
  JSON null (empty string 400s); Set-Cookie set/deleted to mirror; calendar
  cache invalidated on change.
- iCal feed resolves the owner's language; action page resolves the request;
  /debug/set_language repurposed to set DEFAULT_LANGUAGE at runtime (keeps the
  existing ical-language e2e scenario working).
- Login top-bar: hand-rolled flag dropdown left of the theme toggle (the
  public bundle ships no Materialize JS); prefs modal gets a Materialize
  FormSelect Language row with a Default option; reload only if changed.
- <html lang> stamped server-side; flag SVGs from lipis/flag-icons (MIT).
- Schema 18 -> 19 (user_prefs.language); 5 locale files gain name/flag +
  Language/Default phrases (284 keys each); docs updated; e2e container runs
  with WARP_LANGUAGES=["en","de"].

Tests: 21 new pytest (resolve, config validation, prefs/bootstrap sync);
new e2e/settings/language.spec.ts. 137 pytest pass; npm run build OK;
check_i18n.py clean. Full e2e run deferred to the push gate.

See PLAN_language_selection.md (gitignored) for the design + divergences.

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
ical is always bound to a user, so the feed AND the action pages must take the
language from the owner's user_prefs.language (NULL -> DEFAULT_LANGUAGE) and
ignore the warp_lang cookie — a link opened from a different device must render
in the same language as the feed.

- warp/ical.py:_ical_phrases resolves the owner pref only (explicit login, else
  flask.g.ical_owner_login, else DEFAULT_LANGUAGE); it no longer calls
  resolve_language_for_request, so the cookie is never read for ical.
- book_seat/delete_seat set flask.g.ical_owner_login = login; the feed already
  passed login explicitly.
- The generic cancelled page has no <login> in its route, so the confirm page's
  cancel link now carries login as a query param and cancelled() sets
  flask.g.ical_owner_login from it; a direct visit falls back to DEFAULT_LANGUAGE.
- No first-login pinning: a NULL owner pref already falls back to DEFAULT_LANGUAGE,
  so ical is consistent without pinning (decision 1 stands).

Hide the language picker on the ical landing pages: public_nav.html gains a
show_lang_picker gate (default true); ical_action.html sets it false. The theme
toggle remains; login.html and error pages keep the picker.

Smoke-verified: owner pref de + cookie fr -> de (cookie ignored); no owner +
cookie fr -> en default (cookie ignored); /calendar/cancelled renders with no
lang-dropdown markup. 137 pytest pass; build OK.

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
Code review + design refinements:

- iCal action-page chrome is owner-bound (bug fix): the context processor now
  prefers flask.g.ical_owner_login so <html lang>/i18nUrl match the card text
  (owner pref, cookie ignored), not the viewer's cookie.
- Default ships all five locales (LANGUAGES = en,de,fr,es,pl); prod compose
  and the quadlet updated (quadlet's stale WARP_LANGUAGE_FILE replaced).
- Flags re-sourced from lipis/flag-icons 4x3 set (uniform 640x480) — the
  flagcdn SVGs had inconsistent aspect ratios (gb 2:1, de 5:3, fr/es 3:2, pl
  8:5) causing the EN flag to render shorter and rounded corners to apply
  inconsistently.
- Prefs Language control is now an M.Dropdown with flag+endonym items (a
  FormSelect <option> can't render images), moved to the top of the modal;
  inited with container=prefModalEl so the list stays inside the modal. The
  trigger mirrors the Materialize outlined select (3rem, outline-variant
  border) and the unfolded list reuses Materialize .dropdown-content defaults
  so it matches the Default plan/day dropdowns; the trigger flag is sized
  20x15 (was unconstrained, rendering at 640x480).
- No "Default" option: the dropdown lists only concrete languages; a NULL pref
  displays as the deployment default language (applied, not selectable) and
  saving without picking keeps NULL. defaultLanguage added to warpGlobals.
  The unused "Default" phrase key removed from all locales (283 keys).
- Login noun/verb: "Login" (username noun) vs "btn.Login" (verb) corrected in
  de/fr/es/pl (de Benutzername, fr Identifiant, es Usuario, pl Nazwa
  użytkownika; btn.Login fr -> "Se connecter").
- Dead code removed: i18n.i18n_filename (unused), bootstrap "active" (computed
  but never returned). debug/set_language now 400s on a code not in LANGUAGES.
- Restored the accidentally-dropped zone_show_booking_preview prefs payload
  field.

Tests/specs updated (language.spec.ts: M.Dropdown item clicks, no Default
option). 137 pytest pass; build OK; check_i18n.py clean (283 keys). Full e2e
run deferred to the push gate. See PLAN_language_selection.md §15 (entries
13-17).

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
Co-Authored-By: Kimi Code 2.7 <noreply@moonshot.ai>
Address the review of feature/language-selection. The earlier
"prefs save pins NULL + cookie clear via a stale-tab snapshot" fix
landed in 510889b; this batch covers the remaining review items.

Backend
  prefs.py: #1 get_user_prefs coerces a stored language no longer in
    LANGUAGES to None (no more "can never save again" brick on a
    code the deployment removed — single-language deployments had no
    UI recovery). #4 `language` is now optional in the schema +
    handler; an omitted key preserves the stored pref + cookie
    (stale tab / failed GET / slow GET overwriting in-flight picks
    no longer wipe it silently). The null/delete-cookie branch stays
    (a no-pref user saving sends null -> preserves NULL).
  ical.py: #5 book_seat/delete_seat set g.ical_owner_login only AFTER
    the HMAC token validates; cancelled() re-validates the delete
    token before resolving owner prefs — a garbage-token / bare
    ?login= action page renders in DEFAULT_LANGUAGE, closing the
    unauthenticated "which logins have a non-default language" oracle.
  i18n.py + ical.py: #7 _load_meta also caches each locale's ical
    phrases at init (the file was parsed and discarded); added
    owner_language() memoized on flask.g so the action-page card text
    and the chrome context processor share ONE UserPrefs read per
    render (4 -> 1); _ical_phrases memoizes per request. iCal relies
    on owner DB prefs only (#5).
  debug.py: #3 dropped the language_file back-compat in
    /debug/set_language (code only).

Frontend
  prefs.js: #4 omits `language` from the POST when loadedPrefs===null.
  style.css: #6 .pref-lang-dropdown li > a.active (was li.active > a,
    which never matched — active is on the <a>, not the <li>).
  base.html: #10 dropped dead `languages` from warpGlobals (menus
    are server-rendered; no JS consumer; only lang/defaultLanguage
    are read).

e2e
  helpers/debug.ts + fixtures.ts: #3 setLanguage/resetServerLanguage
    now POST {language: code}.
  language.spec.ts: #9 openPrefs uses openUserMenu() (anti-flake
    guard) + awaits the populated .pref-lang-name instead of the
    banned waitForTimeout(300); #2 header rewritten to the
    applied-not-selectable design.

Docs
  CONFIGURATION.md / FEATURES.md / containers/README.md: #2/#3/#8
    described the no-'Default'-option design; LANGUAGES default is
    now ["en","de","fr","es","pl"] (renders by default); removed the
    WARP_LANGUAGE_FILE breaking-change box (no traces remain).

Tests
  test_prefs_language.py: +2 regression tests — stale-stored
    language coerced to None on GET (#1), and omitting `language`
    preserves the stored pref + cookie, no Set-Cookie (#4).

Verification: pytest test_i18n_resolve + test_config_languages +
  test_prefs_language (11, incl. 2 new) green; iCal oracle/memoize
  smoke-checked against dev DB; res/check_i18n.py clean; js build OK.
  Full e2e run deferred to the push gate.

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
postPrefs POSTed a boot-time snapshot (DEFAULT_TIME, empty toggles, default
plan/day) when the /xhr/prefs GET had not resolved yet — a stale tab, a failed
GET, or a GET still in flight at modal open. That overwrote the stored prefs.
The previous fix only guarded the language field; the other fields had the
same race. Refuse the whole POST until loadedPrefs is set, calling back with
an error so the existing "Error saving preferences" toast fires. The GET fires
at shell boot, so it has resolved in practice by the time the modal opens; a
permanently-failed GET now leaves the user unable to save (safe) rather than
silently wiping their prefs.

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
Three tests in language.spec.ts failed under full-suite load (passed in
isolation). All were test-side issues; the app under feature/language-selection
is correct (POST /xhr/prefs language=de -> 200 -> DB user1|de verified).

- openPrefs: match the menu link by href="#pref_modal", not English text
  ("Preferences") — this spec seeds/switches non-English languages, so the
  link reads e.g. "Einstellungen" and the text match timed out (F1).
- openPrefs: register waitForResponse(/xhr/prefs GET) BEFORE page.goto so it
  captures this boot's GET. An after-goto listener missed it under load (the
  GET completes while goto() awaits 'load'), leaving postPrefs' loadedPrefs
  ===null guard to no-op the save, and a late applyPrefsToUI to overwrite an
  in-progress selection to null (F2).
- savePrefsAndReload: await the POST response (.ok()) first — the client only
  reloads in its .then() after the upsert committed — then await the modal
  leaving the DOM (reload replaces the document) before waitForViewReady.
  A plain click()+waitForViewReady matched the pre-reload body[data-view-ready]
  and raced the DB read; a pre-armed waitForURL variant let the reload abort
  the POST (status -1, 'de' never persisted) (F2).
- seed the stale warp_lang cookie on getRuntimeInfo().baseURL, not
  http://localhost — a localhost cookie is never sent to the random-port
  127.0.0.1 app and masked the cookie bootstrap actually sets (F3). Same trap
  plan-management.spec.ts documents for warp_theme.

Full tests/settings/ passes 90/90 across repeats; full e2e suite 538/538.

Co-Authored-By: GLM 5.2 <glm-5.2@z.ai>
@sebo-b sebo-b changed the title Per-user language selection (with iCal owner-bound language) Per-user language selection Jul 7, 2026
@sebo-b
sebo-b merged commit d4293ce into main Jul 7, 2026
2 checks passed
@sebo-b
sebo-b deleted the feature/language-selection branch July 7, 2026 06:09
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