diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index fe4a2b1..c4c209b 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -35,7 +35,7 @@ export const state = { current: new Date(), // currently displayed month lastDutiesForList: [], // duties array for the duty list todayRefreshInterval: null, // interval handle - lang: "ru" // 'ru' | 'en' + lang: "en" // 'ru' | 'en' }; ``` @@ -58,7 +58,7 @@ t(lang, key, params?) // → translated string ``` - `lang` is `'ru'` or `'en'`, stored in `state.lang`. -- `getLang()` resolves language from: Telegram `initData` user → `navigator.language` → `"ru"`. +- `getLang()` reads **backend config** only: `window.__DT_LANG` (set by `/app/config.js` from `DEFAULT_LANGUAGE`). If missing or invalid, falls back to `"en"`. No Telegram or navigator language detection. - Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`. - Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string. - All user-visible text must go through `t()` — never hardcode Russian strings in JS. diff --git a/.env.example b/.env.example index 1403447..bb36433 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ ADMIN_USERNAMES=admin1,admin2 # When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable. # DUTY_PIN_NOTIFY=1 -# Default UI language when user language is unknown: en or ru (default: en). +# Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection. # DEFAULT_LANGUAGE=en # Reject Telegram initData older than this (seconds). 0 = do not check (default). diff --git a/docs/configuration.md b/docs/configuration.md index 130049d..43bd72b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,7 +19,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv). | **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. | | **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. | | **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. | -| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. | +| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. | ## Roles and access diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index 1361288..21d17ed 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -5,7 +5,6 @@ import re from datetime import date, timedelta import duty_teller.config as config -from starlette.middleware.base import BaseHTTPMiddleware from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware @@ -58,22 +57,67 @@ app.add_middleware( ) -class NoCacheStaticMiddleware(BaseHTTPMiddleware): - """Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.).""" +class NoCacheStaticMiddleware: + """ + Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files; + Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another. + """ - async def dispatch(self, request, call_next): - response = await call_next(request) - path = request.url.path - if path.startswith("/app/") and ( - path.endswith(".js") or path.endswith(".html") - ): - response.headers["Cache-Control"] = "no-store" - return response + def __init__(self, app, **kwargs): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + is_app_path = path == "/app" or path.startswith("/app/") + + async def send_wrapper(message): + if message["type"] == "http.response.start": + headers = list(message.get("headers", [])) + header_names = {h[0].lower(): i for i, h in enumerate(headers)} + if is_app_path: + cache_control = (b"cache-control", b"no-store") + if b"cache-control" in header_names: + headers[header_names[b"cache-control"]] = cache_control + else: + headers.append(cache_control) + vary_val = b"Accept-Language" + if b"vary" in header_names: + idx = header_names[b"vary"] + existing = headers[idx][1] + tokens = [p.strip() for p in existing.split(b",")] + if vary_val not in tokens: + headers[idx] = (b"vary", existing + b", " + vary_val) + else: + headers.append((b"vary", vary_val)) + message = {"type": "http.response.start", "status": message["status"], "headers": headers} + await send(message) + + await self.app(scope, receive, send_wrapper) app.add_middleware(NoCacheStaticMiddleware) +@app.get( + "/app/config.js", + summary="Mini App config (language)", + description="Returns JS that sets window.__DT_LANG from DEFAULT_LANGUAGE. Loaded before main.js.", +) +def app_config_js() -> Response: + """Return JS assigning window.__DT_LANG for the webapp. No caching.""" + lang = config.DEFAULT_LANGUAGE + body = f'window.__DT_LANG = "{lang}";' + return Response( + content=body, + media_type="application/javascript; charset=utf-8", + headers={"Cache-Control": "no-store"}, + ) + + @app.get( "/api/duties", response_model=list[DutyWithUser], diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index 835adeb..f032598 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -1,7 +1,6 @@ """FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation.""" import logging -import re from typing import Annotated, Generator from fastapi import Depends, Header, HTTPException, Query, Request @@ -17,42 +16,17 @@ from duty_teller.db.repository import ( from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser from duty_teller.db.session import session_scope from duty_teller.i18n import t -from duty_teller.i18n.lang import normalize_lang from duty_teller.utils.dates import DateRangeValidationError, validate_date_range log = logging.getLogger(__name__) -# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru"). -_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)") - - -def _parse_first_language_code(header: str | None) -> str | None: - """Extract the first language code from Accept-Language header. - - Args: - header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8"). - - Returns: - Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid. - """ - if not header or not header.strip(): - return None - first = header.strip().split(",")[0].strip() - m = _ACCEPT_LANG_CODE_RE.match(first) - return m.group(1).lower() if m else None - - def _lang_from_accept_language(header: str | None) -> str: - """Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE. + """Return the application language: always config.DEFAULT_LANGUAGE. - Args: - header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8"). - - Returns: - 'ru' or 'en'. + The header argument is kept for backward compatibility but is ignored. + The whole deployment uses a single language from DEFAULT_LANGUAGE. """ - code = _parse_first_language_code(header) - return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE) + return config.DEFAULT_LANGUAGE def _auth_error_detail(auth_reason: str, lang: str) -> str: diff --git a/duty_teller/api/telegram_auth.py b/duty_teller/api/telegram_auth.py index 73d7b9c..65d4128 100644 --- a/duty_teller/api/telegram_auth.py +++ b/duty_teller/api/telegram_auth.py @@ -6,7 +6,7 @@ import json import time from urllib.parse import unquote -from duty_teller.i18n.lang import normalize_lang +import duty_teller.config as config # Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app # Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret. @@ -48,12 +48,12 @@ def validate_init_data_with_reason( Returns: Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", - "user_invalid", "no_user_id". lang is from user.language_code normalized - to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None, - "ok", lang). + "user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE. + On success: (user.id, username or None, "ok", lang). """ + lang = config.DEFAULT_LANGUAGE if not init_data or not bot_token: - return (None, None, "empty", "en") + return (None, None, "empty", lang) init_data = init_data.strip() params = {} for part in init_data.split("&"): @@ -65,7 +65,7 @@ def validate_init_data_with_reason( params[key] = value hash_val = params.pop("hash", None) if not hash_val: - return (None, None, "no_hash", "en") + return (None, None, "no_hash", lang) data_pairs = sorted(params.items()) # Data-check string: key=value with URL-decoded values (per Telegram example) data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs) @@ -81,27 +81,26 @@ def validate_init_data_with_reason( digestmod=hashlib.sha256, ).hexdigest() if not hmac.compare_digest(computed.lower(), hash_val.lower()): - return (None, None, "hash_mismatch", "en") + return (None, None, "hash_mismatch", lang) if max_age_seconds is not None and max_age_seconds > 0: auth_date_raw = params.get("auth_date") if not auth_date_raw: - return (None, None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", lang) try: auth_date = int(float(auth_date_raw)) except (ValueError, TypeError): - return (None, None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", lang) if time.time() - auth_date > max_age_seconds: - return (None, None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", lang) user_raw = params.get("user") if not user_raw: - return (None, None, "no_user", "en") + return (None, None, "no_user", lang) try: user = json.loads(unquote(user_raw)) except (json.JSONDecodeError, TypeError): - return (None, None, "user_invalid", "en") + return (None, None, "user_invalid", lang) if not isinstance(user, dict): - return (None, None, "user_invalid", "en") - lang = normalize_lang(user.get("language_code")) + return (None, None, "user_invalid", lang) raw_id = user.get("id") if raw_id is None: return (None, None, "no_user_id", lang) diff --git a/duty_teller/i18n/core.py b/duty_teller/i18n/core.py index ddb332e..83b0fa4 100644 --- a/duty_teller/i18n/core.py +++ b/duty_teller/i18n/core.py @@ -1,9 +1,8 @@ -"""get_lang and t(): language from Telegram user, translate by key with fallback to en.""" +"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en.""" from typing import TYPE_CHECKING import duty_teller.config as config -from duty_teller.i18n.lang import normalize_lang from duty_teller.i18n.messages import MESSAGES if TYPE_CHECKING: @@ -12,13 +11,12 @@ if TYPE_CHECKING: def get_lang(user: "User | None") -> str: """ - Normalize Telegram user language to 'ru' or 'en'. - Uses normalize_lang for user.language_code; when user is None or has no - language_code, returns config.DEFAULT_LANGUAGE. + Return the application language: always config.DEFAULT_LANGUAGE. + + The user argument is kept for backward compatibility but is ignored. + The whole deployment uses a single language from DEFAULT_LANGUAGE. """ - if user is None or not getattr(user, "language_code", None): - return config.DEFAULT_LANGUAGE - return normalize_lang(user.language_code) + return config.DEFAULT_LANGUAGE def t(lang: str, key: str, **kwargs: str) -> str: diff --git a/tests/test_api_dependencies.py b/tests/test_api_dependencies.py index e68a2fd..599b10b 100644 --- a/tests/test_api_dependencies.py +++ b/tests/test_api_dependencies.py @@ -10,24 +10,27 @@ import duty_teller.config as config class TestLangFromAcceptLanguage: - """Tests for _lang_from_accept_language.""" + """Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE.""" - def test_none_returns_default(self): + def test_always_returns_default_language(self): + """Header is ignored; result is always config.DEFAULT_LANGUAGE.""" assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE - - def test_empty_string_returns_default(self): assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE + assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE + assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE + assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE + assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE - def test_ru_ru_returns_ru(self): - assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru" + def test_returns_ru_when_default_language_is_ru(self): + with patch.object(config, "DEFAULT_LANGUAGE", "ru"): + assert deps._lang_from_accept_language("en-US") == "ru" + assert deps._lang_from_accept_language(None) == "ru" - def test_en_us_returns_en(self): - assert deps._lang_from_accept_language("en-US") == "en" - - def test_invalid_fallback_to_en(self): - assert deps._lang_from_accept_language("zz") == "en" - assert deps._lang_from_accept_language("x") == "en" + def test_returns_en_when_default_language_is_en(self): + with patch.object(config, "DEFAULT_LANGUAGE", "en"): + assert deps._lang_from_accept_language("ru-RU") == "en" + assert deps._lang_from_accept_language(None) == "en" class TestAuthErrorDetail: diff --git a/tests/test_app.py b/tests/test_app.py index 1281f15..c78d202 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -23,6 +23,41 @@ def test_health(client): assert r.json() == {"status": "ok"} +def test_health_has_vary_accept_language(client): + """NoCacheStaticMiddleware adds Vary: Accept-Language to all responses.""" + r = client.get("/health") + assert r.status_code == 200 + assert "accept-language" in r.headers.get("vary", "").lower() + + +def test_app_static_has_no_store_and_vary(client): + """Static files under /app get Cache-Control: no-store and Vary: Accept-Language.""" + r = client.get("/app/") + if r.status_code != 200: + r = client.get("/app") + assert r.status_code == 200, "webapp static mount should serve index at /app or /app/" + assert r.headers.get("cache-control") == "no-store" + assert "accept-language" in r.headers.get("vary", "").lower() + + +def test_app_js_has_no_store(client): + """JS and all static under /app get Cache-Control: no-store.""" + r = client.get("/app/js/main.js") + assert r.status_code == 200 + assert r.headers.get("cache-control") == "no-store" + + +def test_app_config_js_returns_lang_from_default_language(client): + """GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE.""" + r = client.get("/app/config.js") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/javascript") + assert r.headers.get("cache-control") == "no-store" + body = r.text + assert "window.__DT_LANG" in body + assert config.DEFAULT_LANGUAGE in body + + def test_duties_invalid_date_format(client): r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"}) assert r.status_code == 400 diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 8a6db5d..6e8df5a 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -1,48 +1,46 @@ """Unit tests for duty_teller.i18n: get_lang, t, fallback to en.""" -from unittest.mock import MagicMock - +from unittest.mock import MagicMock, patch +import duty_teller.config as config from duty_teller.i18n import get_lang, t -def test_get_lang_none_returns_en(): - assert get_lang(None) == "en" +def test_get_lang_always_returns_default_language(): + """get_lang ignores user and always returns config.DEFAULT_LANGUAGE.""" + assert get_lang(None) == config.DEFAULT_LANGUAGE + user_ru = MagicMock() + user_ru.language_code = "ru" + assert get_lang(user_ru) == config.DEFAULT_LANGUAGE + user_en = MagicMock() + user_en.language_code = "en" + assert get_lang(user_en) == config.DEFAULT_LANGUAGE + user_any = MagicMock(spec=[]) + assert get_lang(user_any) == config.DEFAULT_LANGUAGE -def test_get_lang_ru_returns_ru(): - user = MagicMock() - user.language_code = "ru" - assert get_lang(user) == "ru" +def test_get_lang_returns_ru_when_default_language_is_ru(): + """When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user.""" + with patch("duty_teller.i18n.core.config") as mock_cfg: + mock_cfg.DEFAULT_LANGUAGE = "ru" + from duty_teller.i18n.core import get_lang as core_get_lang + + assert core_get_lang(None) == "ru" + user = MagicMock() + user.language_code = "en" + assert core_get_lang(user) == "ru" -def test_get_lang_ru_ru_returns_ru(): - user = MagicMock() - user.language_code = "ru-RU" - assert get_lang(user) == "ru" +def test_get_lang_returns_en_when_default_language_is_en(): + """When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user.""" + with patch("duty_teller.i18n.core.config") as mock_cfg: + mock_cfg.DEFAULT_LANGUAGE = "en" + from duty_teller.i18n.core import get_lang as core_get_lang - -def test_get_lang_en_returns_en(): - user = MagicMock() - user.language_code = "en" - assert get_lang(user) == "en" - - -def test_get_lang_uk_returns_en(): - user = MagicMock() - user.language_code = "uk" - assert get_lang(user) == "en" - - -def test_get_lang_empty_returns_en(): - user = MagicMock() - user.language_code = "" - assert get_lang(user) == "en" - - -def test_get_lang_missing_attr_returns_en(): - user = MagicMock(spec=[]) # no language_code - assert get_lang(user) == "en" + assert core_get_lang(None) == "en" + user = MagicMock() + user.language_code = "ru" + assert core_get_lang(user) == "en" def test_t_en_start_greeting(): diff --git a/tests/test_telegram_auth.py b/tests/test_telegram_auth.py index 0820b3d..663b81a 100644 --- a/tests/test_telegram_auth.py +++ b/tests/test_telegram_auth.py @@ -1,5 +1,8 @@ """Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason.""" +from unittest.mock import patch + +import duty_teller.config as config from duty_teller.api.telegram_auth import ( validate_init_data, validate_init_data_with_reason, @@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data(): def test_user_without_username_but_with_id_succeeds_with_reason(): - """With validate_init_data_with_reason, valid user.id is enough; username may be None.""" + """With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE.""" bot_token = "123:ABC" user = {"id": 456, "first_name": "Test", "language_code": "ru"} init_data = make_init_data(user, bot_token) @@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason(): assert telegram_user_id == 456 assert username is None assert reason == "ok" - assert lang == "ru" + assert lang == config.DEFAULT_LANGUAGE def test_user_without_id_returns_no_user_id(): - """When user object exists but has no 'id', return no_user_id.""" + """When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE.""" bot_token = "123:ABC" user = {"first_name": "Test"} # no id init_data = make_init_data(user, bot_token) @@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id(): assert telegram_user_id is None assert username is None assert reason == "no_user_id" - assert lang == "en" + assert lang == config.DEFAULT_LANGUAGE + + +def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang(): + """Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code.""" + with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"): + user = {"id": 1, "first_name": "U", "language_code": "en"} + init_data = make_init_data(user, "123:ABC") + _, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC") + assert reason == "ok" + assert lang == "ru" def test_empty_init_data_returns_none(): diff --git a/webapp/css/calendar.css b/webapp/css/calendar.css index f6036d0..6551267 100644 --- a/webapp/css/calendar.css +++ b/webapp/css/calendar.css @@ -152,6 +152,19 @@ border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent); } +@media (hover: hover) { + .day.holiday:hover { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%, + color-mix(in srgb, var(--today) 22%, transparent) 100% + ); + } + .day.today.holiday:hover { + background: color-mix(in srgb, var(--bg) 15%, var(--today)); + } +} + .day { cursor: pointer; } diff --git a/webapp/index.html b/webapp/index.html index addfab1..f2b11a8 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -36,15 +36,66 @@ + + - + + diff --git a/webapp/js/api.js b/webapp/js/api.js index 4204988..c7d2d1d 100644 --- a/webapp/js/api.js +++ b/webapp/js/api.js @@ -17,7 +17,7 @@ import { t } from "./i18n.js"; export function buildFetchOptions(initData, externalSignal) { const headers = {}; if (initData) headers["X-Telegram-Init-Data"] = initData; - headers["Accept-Language"] = state.lang || "ru"; + headers["Accept-Language"] = state.lang || "en"; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const onAbort = () => controller.abort(); diff --git a/webapp/js/auth.js b/webapp/js/auth.js index 9bed5fa..1f5dd10 100644 --- a/webapp/js/auth.js +++ b/webapp/js/auth.js @@ -34,23 +34,15 @@ export function getInitData() { if (hash) { const fromHash = getTgWebAppDataFromHash(hash); if (fromHash) return fromHash; - try { - const hashParams = new URLSearchParams(hash); - const tgFromHash = hashParams.get("tgWebAppData"); - if (tgFromHash) return decodeURIComponent(tgFromHash); - } catch (e) { - /* ignore */ - } + const hashParams = new URLSearchParams(hash); + const tgFromHash = hashParams.get("tgWebAppData"); + if (tgFromHash) return tgFromHash; } const q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null; if (q) { - try { - return decodeURIComponent(q); - } catch (e) { - return q; - } + return q; } return ""; } diff --git a/webapp/js/auth.test.js b/webapp/js/auth.test.js index 8b1d1aa..364fd31 100644 --- a/webapp/js/auth.test.js +++ b/webapp/js/auth.test.js @@ -66,6 +66,28 @@ describe("getInitData", () => { window.location = { ...origLocation, hash: "", search: "" }; expect(getInitData()).toBe(""); }); + + it("returns data from hash query param without double-decoding", () => { + window.Telegram = { WebApp: { initData: "" } }; + delete window.location; + window.location = { + ...origLocation, + hash: "#tgWebAppVersion=6&tgWebAppData=user%3Dname%26hash%3Dabc", + search: "", + }; + expect(getInitData()).toBe("user=name&hash=abc"); + }); + + it("returns data from search query param without double-decoding", () => { + window.Telegram = { WebApp: { initData: "" } }; + delete window.location; + window.location = { + ...origLocation, + hash: "", + search: "?tgWebAppData=user%3Dname%26hash%3Dabc", + }; + expect(getInitData()).toBe("user=name&hash=abc"); + }); }); describe("isLocalhost", () => { diff --git a/webapp/js/dom.js b/webapp/js/dom.js index 6970843..fc2d857 100644 --- a/webapp/js/dom.js +++ b/webapp/js/dom.js @@ -67,7 +67,7 @@ export const state = { /** @type {ReturnType|null} */ todayRefreshInterval: null, /** @type {'ru'|'en'} */ - lang: "ru", + lang: "en", /** One-time bind flag for sticky scroll shadow listener. */ stickyScrollBound: false, /** One-time bind flag for calendar (info button) hint document listeners. */ diff --git a/webapp/js/i18n.js b/webapp/js/i18n.js index 6ceb7e0..724cea5 100644 --- a/webapp/js/i18n.js +++ b/webapp/js/i18n.js @@ -1,9 +1,7 @@ /** - * Internationalization: language detection and translations for webapp. + * Internationalization: language from backend config (window.__DT_LANG) and translations. */ -import { getInitData } from "./auth.js"; - /** @type {Record>} */ export const MESSAGES = { en: { @@ -135,34 +133,25 @@ const WEEKDAY_KEYS = [ * @param {string} code - e.g. 'ru', 'en', 'uk' * @returns {'ru'|'en'} */ -function normalizeLang(code) { - if (!code || typeof code !== "string") return "ru"; +export function normalizeLang(code) { + if (!code || typeof code !== "string") return "en"; const lower = code.toLowerCase(); if (lower.startsWith("ru")) return "ru"; return "en"; } /** - * Detect language: Telegram initData user.language_code → navigator → fallback 'ru'. + * Get application language from backend config (window.__DT_LANG). + * Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid. * @returns {'ru'|'en'} */ export function getLang() { - const initData = getInitData(); - if (initData) { - try { - const params = new URLSearchParams(initData); - const userStr = params.get("user"); - if (userStr) { - const user = JSON.parse(decodeURIComponent(userStr)); - const code = user && user.language_code; - if (code) return normalizeLang(code); - } - } catch (e) { - /* ignore */ - } - } - const nav = navigator.language || (navigator.languages && navigator.languages[0]) || ""; - return normalizeLang(nav); + const raw = + typeof window !== "undefined" && window.__DT_LANG != null + ? String(window.__DT_LANG) + : ""; + const lang = normalizeLang(raw); + return lang === "ru" ? "ru" : "en"; } /** diff --git a/webapp/js/i18n.test.js b/webapp/js/i18n.test.js index 36380d6..8846f7c 100644 --- a/webapp/js/i18n.test.js +++ b/webapp/js/i18n.test.js @@ -1,50 +1,76 @@ /** - * Unit tests for i18n: getLang, t (fallback, params), monthName. + * Unit tests for i18n: getLang (window.__DT_LANG), normalizeLang, t (fallback, params), monthName. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; -const mockGetInitData = vi.fn(); -vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() })); - -import { getLang, t, monthName, MESSAGES } from "./i18n.js"; +import { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js"; describe("getLang", () => { - const origNavigator = globalThis.navigator; + const orig__DT_LANG = globalThis.window?.__DT_LANG; - beforeEach(() => { - mockGetInitData.mockReset(); + afterEach(() => { + if (typeof globalThis.window !== "undefined") { + if (orig__DT_LANG !== undefined) { + globalThis.window.__DT_LANG = orig__DT_LANG; + } else { + delete globalThis.window.__DT_LANG; + } + } }); - it("returns lang from initData user when present", () => { - mockGetInitData.mockReturnValue( - "user=" + encodeURIComponent(JSON.stringify({ language_code: "en" })) - ); - expect(getLang()).toBe("en"); - }); - - it("normalizes ru from initData", () => { - mockGetInitData.mockReturnValue( - "user=" + encodeURIComponent(JSON.stringify({ language_code: "ru" })) - ); + it("returns ru when window.__DT_LANG is ru", () => { + globalThis.window.__DT_LANG = "ru"; expect(getLang()).toBe("ru"); }); - it("falls back to navigator.language when initData empty", () => { - mockGetInitData.mockReturnValue(""); - Object.defineProperty(globalThis, "navigator", { - value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] }, - configurable: true, - }); + it("returns en when window.__DT_LANG is en", () => { + globalThis.window.__DT_LANG = "en"; expect(getLang()).toBe("en"); }); - it("normalizes to en for unknown language code", () => { - mockGetInitData.mockReturnValue( - "user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" })) - ); + it("returns en when window.__DT_LANG is missing", () => { + delete globalThis.window.__DT_LANG; expect(getLang()).toBe("en"); }); + + it("returns en when window.__DT_LANG is invalid (unknown code)", () => { + globalThis.window.__DT_LANG = "uk"; + expect(getLang()).toBe("en"); + }); + + it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => { + globalThis.window.__DT_LANG = "ru-RU"; + expect(getLang()).toBe("ru"); + }); + + it("returns en when window.__DT_LANG is empty string", () => { + globalThis.window.__DT_LANG = ""; + expect(getLang()).toBe("en"); + }); + + it("returns en when window.__DT_LANG is null", () => { + globalThis.window.__DT_LANG = null; + expect(getLang()).toBe("en"); + }); +}); + +describe("normalizeLang", () => { + it("returns ru for ru-like codes", () => { + expect(normalizeLang("ru")).toBe("ru"); + expect(normalizeLang("ru-RU")).toBe("ru"); + }); + + it("returns en for en and others", () => { + expect(normalizeLang("en")).toBe("en"); + expect(normalizeLang("en-US")).toBe("en"); + expect(normalizeLang("uk")).toBe("en"); + }); + + it("returns en for empty or invalid", () => { + expect(normalizeLang("")).toBe("en"); + expect(normalizeLang(null)).toBe("en"); + }); }); describe("t", () => { diff --git a/webapp/js/main.js b/webapp/js/main.js index c90587d..6fafc7a 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -37,23 +37,35 @@ import { initTheme(); state.lang = getLang(); -document.documentElement.lang = state.lang; -document.title = t(state.lang, "app.title"); -const loadingEl = getLoadingEl(); -const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; -if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); -const dayLabels = weekdayLabels(state.lang); -const weekdaysEl = getWeekdaysEl(); -if (weekdaysEl) { - const spans = weekdaysEl.querySelectorAll("span"); - spans.forEach((span, i) => { - if (dayLabels[i]) span.textContent = dayLabels[i]; - }); + +/** + * Apply current state.lang to document and locale-dependent UI elements (title, loading, weekdays, nav). + * Call at startup and after re-evaluating lang when initData becomes available. + * Exported for tests. + */ +export function applyLangToUi() { + document.documentElement.lang = state.lang; + document.title = t(state.lang, "app.title"); + const loadingEl = getLoadingEl(); + const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; + if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); + const dayLabels = weekdayLabels(state.lang); + const weekdaysEl = getWeekdaysEl(); + if (weekdaysEl) { + const spans = weekdaysEl.querySelectorAll("span"); + spans.forEach((span, i) => { + if (dayLabels[i]) span.textContent = dayLabels[i]; + }); + } + const prevBtn = getPrevBtn(); + const nextBtn = getNextBtn(); + if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month")); + if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); } -const prevBtn = getPrevBtn(); -const nextBtn = getNextBtn(); -if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month")); -if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); + +applyLangToUi(); + +window.__dtReady = true; /** * Run callback when Telegram WebApp is ready (or immediately outside Telegram). @@ -62,9 +74,6 @@ if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")) */ function runWhenReady(cb) { if (window.Telegram && window.Telegram.WebApp) { - if (window.Telegram.WebApp.ready) { - window.Telegram.WebApp.ready(); - } if (window.Telegram.WebApp.expand) { window.Telegram.WebApp.expand(); } @@ -193,6 +202,8 @@ async function loadMonth() { setNavEnabled(true); return; } + const errorEl2 = getErrorEl(); + if (errorEl2) errorEl2.hidden = true; const loading = getLoadingEl(); if (loading) loading.classList.add("hidden"); setNavEnabled(true); @@ -276,6 +287,11 @@ function bindStickyScrollShadow() { runWhenReady(() => { requireTelegramOrLocalhost(() => { + const newLang = getLang(); + if (newLang !== state.lang) { + state.lang = newLang; + applyLangToUi(); + } bindStickyScrollShadow(); initDayDetail(); initHints(); @@ -284,7 +300,6 @@ runWhenReady(() => { window.Telegram.WebApp.initDataUnsafe.start_param) || ""; if (startParam === "duty") { - state.lang = getLang(); showCurrentDutyView(() => { hideCurrentDutyView(); loadMonth(); diff --git a/webapp/js/main.test.js b/webapp/js/main.test.js new file mode 100644 index 0000000..52cec7c --- /dev/null +++ b/webapp/js/main.test.js @@ -0,0 +1,69 @@ +/** + * Unit tests for main.js: applyLangToUi (locale-to-DOM) and lang re-evaluation behaviour. + */ + +import { describe, it, expect, beforeAll } from "vitest"; + +beforeAll(() => { + document.body.innerHTML = + '
' + + '

' + + '
' + + '' + + "
" + + '
' + + '
' + + '
'; +}); + +import { applyLangToUi } from "./main.js"; +import { state } from "./dom.js"; + +describe("applyLangToUi", () => { + it("state.lang is set from getLang() at startup (getLang reads window.__DT_LANG; default en)", () => { + expect(state.lang).toBe("en"); + }); + + it("sets document title and loading text for en", () => { + state.lang = "en"; + applyLangToUi(); + expect(document.title).toBe("Duty Calendar"); + const loadingText = document.querySelector(".loading__text"); + expect(loadingText && loadingText.textContent).toBe("Loading…"); + }); + + it("sets document title and loading text for ru", () => { + state.lang = "ru"; + applyLangToUi(); + expect(document.title).toBe("Календарь дежурств"); + const loadingText = document.querySelector(".loading__text"); + expect(loadingText && loadingText.textContent).toBe("Загрузка…"); + }); + + it("sets documentElement.lang to state.lang", () => { + state.lang = "en"; + applyLangToUi(); + expect(document.documentElement.lang).toBe("en"); + state.lang = "ru"; + applyLangToUi(); + expect(document.documentElement.lang).toBe("ru"); + }); + + it("sets weekday labels and nav aria-labels for current lang", () => { + state.lang = "en"; + applyLangToUi(); + const weekdays = document.querySelectorAll(".weekdays span"); + expect(weekdays.length).toBe(7); + expect(weekdays[0].textContent).toBe("Mon"); + const prevBtn = document.getElementById("prevMonth"); + const nextBtn = document.getElementById("nextMonth"); + expect(prevBtn && prevBtn.getAttribute("aria-label")).toBe("Previous month"); + expect(nextBtn && nextBtn.getAttribute("aria-label")).toBe("Next month"); + + state.lang = "ru"; + applyLangToUi(); + expect(weekdays[0].textContent).toBe("Пн"); + expect(prevBtn && prevBtn.getAttribute("aria-label")).toContain("Пред"); + expect(nextBtn && nextBtn.getAttribute("aria-label")).toContain("След"); + }); +});