Per-user language selection - #79
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements per-user UI language selection per
PLAN_language_selection.md, refined through the_CODE_REVIEW.mdfixes.What
LANGUAGE_FILE) to a per-user choice carried across login/logout via awarp_langcookie.LANGUAGES(JSON array) +DEFAULT_LANGUAGE;LANGUAGE_FILEremoved (breaking env-var change, noted inCONFIGURATION.md)./xhr/bootstrap(covers every auth backend).M.Dropdownwith flags); no "Default" entry — a NULL pref shows the deployment default applied, not selectable.user_prefs.language, NULL →DEFAULT_LANGUAGE); thewarp_langcookie is never read for ical.user_prefs.language); flag SVGs (lipis/flag-icons, MIT, attributed inREADME.md).check_i18n.pyclean.Tests
test_config_languages.py,test_i18n_resolve.py,test_prefs_language.py(DB-backed).tests/settings/language.spec.ts.tests/settings/ical-language.spec.tsretained via a back-compatdebug/set_languagerepurposing.Latest commit — e2e flake fixes (6345be4)
Three
language.spec.tstests failed under full-suite load (passed in isolation) — all test-side; the app is correct (POST/xhr/prefslanguage=de → 200 → DBuser1|deverified):href="#pref_modal", not English text (this spec switches languages).waitForResponse(/xhr/prefs GET)beforepage.gotoso it catches this boot's GET under load.savePrefsAndReload: await the POST response (.ok()) first, then the modal leaving the DOM, beforewaitForViewReady— a pre-armedwaitForURLlet the reload abort the POST (status -1,denever persisted).warp_langcookie ongetRuntimeInfo().baseURL, nothttp://localhost(same trapplan-management.spec.tsdocuments forwarp_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>