feat: unify language handling across the application

- Updated the language configuration to use a single source of truth from `DEFAULT_LANGUAGE` for the bot, API, and Mini App, eliminating auto-detection from user settings.
- Refactored the `get_lang` function to always return `DEFAULT_LANGUAGE`, ensuring consistent language usage throughout the application.
- Modified the handling of language in various components, including API responses and UI elements, to reflect the new language management approach.
- Enhanced documentation and comments to clarify the changes in language handling.
- Added unit tests to verify the new language handling behavior and ensure coverage for the updated functionality.
This commit is contained in:
2026-03-02 23:05:28 +03:00
parent 54446d7b0f
commit 67ba9826c7
21 changed files with 446 additions and 205 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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],

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)
def t(lang: str, key: str, **kwargs: str) -> str:

View File

@@ -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:

View File

@@ -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

View File

@@ -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
def test_get_lang_ru_ru_returns_ru():
user = MagicMock()
user.language_code = "ru-RU"
assert get_lang(user) == "ru"
def test_get_lang_en_returns_en():
assert core_get_lang(None) == "ru"
user = MagicMock()
user.language_code = "en"
assert get_lang(user) == "en"
assert core_get_lang(user) == "ru"
def test_get_lang_uk_returns_en():
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
assert core_get_lang(None) == "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"
user.language_code = "ru"
assert core_get_lang(user) == "en"
def test_t_en_start_greeting():

View File

@@ -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():

View File

@@ -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;
}

View File

@@ -36,15 +36,66 @@
<div id="currentDutyView" class="current-duty-view hidden"></div>
</div>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script>
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.ready) {
window.Telegram.WebApp.ready();
}
</script>
<script src="/app/config.js"></script>
<script type="importmap">
{
"scopes": {
"./js/": {
"./js/i18n.js": "./js/i18n.js?v=1"
"./js/api.js": "./js/api.js?v=3",
"./js/auth.js": "./js/auth.js?v=3",
"./js/calendar.js": "./js/calendar.js?v=2",
"./js/constants.js": "./js/constants.js?v=2",
"./js/contactHtml.js": "./js/contactHtml.js?v=2",
"./js/currentDuty.js": "./js/currentDuty.js?v=2",
"./js/dateUtils.js": "./js/dateUtils.js?v=2",
"./js/dayDetail.js": "./js/dayDetail.js?v=2",
"./js/dom.js": "./js/dom.js?v=3",
"./js/dutyList.js": "./js/dutyList.js?v=2",
"./js/hints.js": "./js/hints.js?v=2",
"./js/i18n.js": "./js/i18n.js?v=3",
"./js/theme.js": "./js/theme.js?v=2",
"./js/ui.js": "./js/ui.js?v=2",
"./js/utils.js": "./js/utils.js?v=2"
}
}
}
</script>
<script type="module" src="js/main.js?v=4"></script>
<script type="module" src="js/main.js?v=5" id="main-module"></script>
<script>
(function() {
var loadTimeout = 10000;
var mainScript = document.getElementById("main-module");
if (mainScript) {
mainScript.addEventListener("error", function() {
var loading = document.getElementById("loading");
if (loading && !loading.classList.contains("hidden")) {
loading.classList.add("hidden");
var err = document.getElementById("error");
if (err) {
err.hidden = false;
err.textContent = "Failed to load app. Check connection and try again.";
}
}
});
}
setTimeout(function() {
if (window.__dtReady) return;
var loading = document.getElementById("loading");
if (loading && !loading.classList.contains("hidden")) {
loading.classList.add("hidden");
var err = document.getElementById("error");
if (err) {
err.hidden = false;
err.textContent = "App is taking too long to load. Check your connection and refresh.";
}
}
}, loadTimeout);
})();
</script>
</body>
</html>

View File

@@ -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();

View File

@@ -34,24 +34,16 @@ 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 */
}
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 "";
}

View File

@@ -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", () => {

View File

@@ -67,7 +67,7 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|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. */

View File

@@ -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<string, Record<string, string>>} */
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";
}
/**

View File

@@ -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", () => {

View File

@@ -37,6 +37,13 @@ import {
initTheme();
state.lang = getLang();
/**
* 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();
@@ -54,6 +61,11 @@ 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();

69
webapp/js/main.test.js Normal file
View File

@@ -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 =
'<div id="calendarSticky">' +
'<button id="prevMonth"></button><h1 id="monthTitle"></h1><button id="nextMonth"></button>' +
'<div class="weekdays">' +
'<span></span><span></span><span></span><span></span><span></span><span></span><span></span>' +
"</div></div>" +
'<div id="dutyList"></div>' +
'<div id="loading"><span class="loading__text"></span></div>' +
'<div id="error"></div><div id="accessDenied"></div>';
});
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("След");
});
});