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

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

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

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("След");
});
});