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:
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
69
webapp/js/main.test.js
Normal 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("След");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user