feat: implement internationalization for duty calendar
All checks were successful
CI / lint-and-test (push) Successful in 14s

- Introduced a new `i18n.js` module for handling translations and language detection, supporting both Russian and English.
- Updated various components to utilize the new translation functions, enhancing user experience by providing localized messages for errors, hints, and UI elements.
- Refactored existing code in `api.js`, `calendar.js`, `dutyList.js`, `hints.js`, and `ui.js` to replace hardcoded strings with translated messages, improving maintainability and accessibility.
- Enhanced the initialization process in `main.js` to set the document language and update UI elements based on the user's language preference.
This commit is contained in:
2026-02-19 16:00:00 +03:00
parent a5f7a5a0ef
commit 4c2d95e776
9 changed files with 262 additions and 51 deletions

View File

@@ -4,15 +4,18 @@
import { FETCH_TIMEOUT_MS } from "./constants.js";
import { getInitData } from "./auth.js";
import { state } from "./dom.js";
import { t } from "./i18n.js";
/**
* Build fetch options with init data header and timeout abort.
* Build fetch options with init data header, Accept-Language and timeout abort.
* @param {string} initData - Telegram init data
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
*/
export function buildFetchOptions(initData) {
const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
return { headers, signal: controller.signal, timeoutId };
@@ -37,7 +40,7 @@ export async function fetchDuties(from, to) {
try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
let detail = "Доступ запрещён";
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
@@ -53,11 +56,11 @@ export async function fetchDuties(from, to) {
err.serverDetail = detail;
throw err;
}
if (!res.ok) throw new Error("Ошибка загрузки");
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
} catch (e) {
if (e.name === "AbortError") {
throw new Error("Не удалось загрузить данные. Проверьте интернет.");
throw new Error(t(state.lang, "error_network"));
}
throw e;
} finally {

View File

@@ -2,8 +2,8 @@
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl } from "./dom.js";
import { MONTHS } from "./constants.js";
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
localDateString,
@@ -116,6 +116,7 @@ export function renderCalendar(
d.getDate() +
'</span><div class="day-markers">';
if (showMarkers) {
const lang = state.lang;
if (dutyList.length) {
html +=
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
@@ -124,23 +125,23 @@ export function renderCalendar(
namesAttr(dutyList) +
'" data-duty-items=\'' +
dutyItemsJson +
"' aria-label=\"Дежурные\">Д</button>";
"' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д</button>";
}
if (unavailableList.length) {
html +=
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
namesAttr(unavailableList) +
'" aria-label="Недоступен">Н</button>';
'" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н</button>';
}
if (vacationList.length) {
html +=
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
namesAttr(vacationList) +
'" aria-label="Отпуск">О</button>';
'" aria-label="' + escapeHtml(t(lang, "aria.vacation")) + '">О</button>';
}
if (hasEvent) {
html +=
'<button type="button" class="info-btn" aria-label="Информация о дне" data-summary="' +
'<button type="button" class="info-btn" aria-label="' + escapeHtml(t(lang, "aria.day_info")) + '" data-summary="' +
escapeHtml(eventSummaries.join("\n")) +
'">i</button>';
}
@@ -151,7 +152,7 @@ export function renderCalendar(
d.setDate(d.getDate() + 1);
}
monthTitleEl.textContent = MONTHS[month] + " " + year;
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
bindInfoButtonTooltips();
bindDutyMarkerTooltips();
}

View File

@@ -7,14 +7,3 @@ export const RETRY_DELAY_MS = 800;
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };
export const MONTHS = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
];
export const EVENT_TYPE_LABELS = {
duty: "Дежурство",
unavailable: "Недоступен",
vacation: "Отпуск"
};

View File

@@ -39,5 +39,7 @@ export const state = {
/** @type {object[]} */
lastDutiesForList: [],
/** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null
todayRefreshInterval: null,
/** @type {'ru'|'en'} */
lang: "ru"
};

View File

@@ -3,7 +3,7 @@
*/
import { dutyListEl, state } from "./dom.js";
import { EVENT_TYPE_LABELS } from "./constants.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
localDateString,
@@ -33,9 +33,10 @@ export function dutyTimelineCardHtml(d, isCurrent) {
} else {
timeStr = startDDMM + " " + startTime + " " + endDDMM + " " + endTime;
}
const lang = state.lang;
const typeLabel = isCurrent
? "Сейчас дежурит"
: (EVENT_TYPE_LABELS[d.event_type] || "Дежурство");
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
@@ -59,15 +60,16 @@ export function dutyTimelineCardHtml(d, isCurrent) {
* @returns {string}
*/
export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
const lang = state.lang;
const typeLabel =
typeLabelOverride != null
? typeLabelOverride
: (EVENT_TYPE_LABELS[d.event_type] || d.event_type);
: (t(lang, "event_type." + (d.event_type || "duty")));
let itemClass = "duty-item duty-item--" + (d.event_type || "duty");
if (extraClass) itemClass += " " + extraClass;
let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") {
timeOrRange = "до " + formatTimeLocal(d.end_at);
timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at);
@@ -98,7 +100,7 @@ export function renderDutyList(duties) {
const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline");
dutyListEl.innerHTML = '<p class="muted">В этом месяце дежурств нет.</p>';
dutyListEl.innerHTML = '<p class="muted">' + t(state.lang, "duty.none_this_month") + "</p>";
return;
}
dutyListEl.classList.add("duty-timeline");
@@ -121,7 +123,9 @@ export function renderDutyList(duties) {
"duty-timeline-day" + (isToday ? " duty-timeline-day--today" : "");
const dateLabel = dateKeyToDDMM(date);
const dateCellHtml = isToday
? '<span class="duty-timeline-date"><span class="duty-timeline-date-label">Сегодня</span><span class="duty-timeline-date-day">' +
? '<span class="duty-timeline-date"><span class="duty-timeline-date-label">' +
escapeHtml(t(state.lang, "duty.today")) +
'</span><span class="duty-timeline-date-day">' +
escapeHtml(dateLabel) +
'</span><span class="duty-timeline-date-dot" aria-hidden="true"></span></span>'
: '<span class="duty-timeline-date">' + escapeHtml(dateLabel) + "</span>";

View File

@@ -2,8 +2,8 @@
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl } from "./dom.js";
import { EVENT_TYPE_LABELS } from "./constants.js";
import { calendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -90,10 +90,12 @@ function getItemFullName(item) {
* @param {number} idx - index in dutyItems
* @param {number} total - dutyItems.length
* @param {string} hintDay - YYYY-MM-DD
* @param {string} sep - separator after "с"/"до" (e.g. " - " for text, "\u00a0" for HTML)
* @param {string} sep - separator after "from"/"to" (e.g. " - " for text, "\u00a0" for HTML)
* @param {string} fromLabel - translated "from" prefix
* @param {string} toLabel - translated "to" prefix
* @returns {string}
*/
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLabel) {
const startAt = item.start_at != null ? item.start_at : item.startAt;
const endAt = item.end_at != null ? item.end_at : item.endAt;
const endHHMM = endAt ? formatHHMM(endAt) : "";
@@ -105,17 +107,17 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
let timePrefix = "";
if (idx === 0) {
if (total === 1 && startSameDay && startHHMM) {
timePrefix = "с" + sep + startHHMM;
timePrefix = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " до" + sep + endHHMM;
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
timePrefix = "до" + sep + endHHMM;
timePrefix = toLabel + sep + endHHMM;
}
} else if (idx > 0) {
if (startHHMM) timePrefix = "с" + sep + startHHMM;
if (startHHMM) timePrefix = fromLabel + sep + startHHMM;
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += (timePrefix ? " " : "") + "до" + sep + endHHMM;
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM;
}
}
return timePrefix;
@@ -126,16 +128,20 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
* @param {object[]} dutyItems
* @param {string} hintDay
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
* @param {string} fromLabel
* @param {string} toLabel
* @returns {{ timePrefix: string, fullName: string }[]}
*/
function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
return dutyItems.map((item, idx) => {
const timePrefix = buildDutyItemTimePrefix(
item,
idx,
dutyItems.length,
hintDay,
timeSep
timeSep,
fromLabel,
toLabel
);
const fullName = getItemFullName(item);
return { timePrefix, fullName };
@@ -148,14 +154,17 @@ function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
* @returns {string}
*/
export function getDutyMarkerHintContent(marker) {
const lang = state.lang;
const type = marker.getAttribute("data-event-type") || "duty";
const label = EVENT_TYPE_LABELS[type] || type;
const label = t(lang, "event_type." + type);
const names = marker.getAttribute("data-names") || "";
const fromLabel = t(lang, "hint.from");
const toLabel = t(lang, "hint.to");
let body;
if (type === "duty") {
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
if (dutyItems.length >= 1 && hasTimes) {
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ");
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel);
body = rows
.map((r) =>
r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName
@@ -176,14 +185,17 @@ export function getDutyMarkerHintContent(marker) {
* @returns {string|null}
*/
export function getDutyMarkerHintHtml(marker) {
const lang = state.lang;
const type = marker.getAttribute("data-event-type") || "duty";
if (type !== "duty") return null;
const names = marker.getAttribute("data-names") || "";
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
const nbsp = "\u00a0";
const fromLabel = t(lang, "hint.from");
const toLabel = t(lang, "hint.to");
let rowHtmls;
if (dutyItems.length >= 1 && hasTimes) {
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp);
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel);
rowHtmls = rows.map((r) => {
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : "";
const sepHtml = r.timePrefix ? '<span class="calendar-event-hint-sep">-</span>' : '<span class="calendar-event-hint-sep"></span>';
@@ -210,7 +222,7 @@ export function getDutyMarkerHintHtml(marker) {
}
if (rowHtmls.length === 0) return null;
return (
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
'<div class="calendar-event-hint-title">' + escapeHtml(t(lang, "hint.duty_title")) + '</div><div class="calendar-event-hint-rows">' +
rowHtmls
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
.join("") +
@@ -244,11 +256,12 @@ export function bindInfoButtonTooltips() {
document.body.appendChild(hintEl);
}
if (!calendarEl) return;
const lang = state.lang;
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = "События:\n" + summary;
const content = t(lang, "hint.events") + "\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
const rect = btn.getBoundingClientRect();

180
webapp/js/i18n.js Normal file
View File

@@ -0,0 +1,180 @@
/**
* Internationalization: language detection and translations for webapp.
*/
import { getInitData } from "./auth.js";
/** @type {Record<string, Record<string, string>>} */
export const MESSAGES = {
en: {
"app.title": "Duty Calendar",
loading: "Loading…",
access_denied: "Access denied.",
error_load_failed: "Load failed",
error_network: "Could not load data. Check your connection.",
error_generic: "Could not load data.",
"nav.prev_month": "Previous month",
"nav.next_month": "Next month",
"weekdays.mon": "Mon",
"weekdays.tue": "Tue",
"weekdays.wed": "Wed",
"weekdays.thu": "Thu",
"weekdays.fri": "Fri",
"weekdays.sat": "Sat",
"weekdays.sun": "Sun",
"month.jan": "January",
"month.feb": "February",
"month.mar": "March",
"month.apr": "April",
"month.may": "May",
"month.jun": "June",
"month.jul": "July",
"month.aug": "August",
"month.sep": "September",
"month.oct": "October",
"month.nov": "November",
"month.dec": "December",
"aria.duty": "On duty",
"aria.unavailable": "Unavailable",
"aria.vacation": "Vacation",
"aria.day_info": "Day info",
"event_type.duty": "Duty",
"event_type.unavailable": "Unavailable",
"event_type.vacation": "Vacation",
"duty.now_on_duty": "On duty now",
"duty.none_this_month": "No duties this month.",
"duty.today": "Today",
"duty.until": "until {time}",
"hint.from": "from",
"hint.to": "until",
"hint.duty_title": "Duty:",
"hint.events": "Events:"
},
ru: {
"app.title": "Календарь дежурств",
loading: "Загрузка…",
access_denied: "Доступ запрещён.",
error_load_failed: "Ошибка загрузки",
error_network: "Не удалось загрузить данные. Проверьте интернет.",
error_generic: "Не удалось загрузить данные.",
"nav.prev_month": "Предыдущий месяц",
"nav.next_month": "Следующий месяц",
"weekdays.mon": "Пн",
"weekdays.tue": "Вт",
"weekdays.wed": "Ср",
"weekdays.thu": "Чт",
"weekdays.fri": "Пт",
"weekdays.sat": "Сб",
"weekdays.sun": "Вс",
"month.jan": "Январь",
"month.feb": "Февраль",
"month.mar": "Март",
"month.apr": "Апрель",
"month.may": "Май",
"month.jun": "Июнь",
"month.jul": "Июль",
"month.aug": "Август",
"month.sep": "Сентябрь",
"month.oct": "Октябрь",
"month.nov": "Ноябрь",
"month.dec": "Декабрь",
"aria.duty": "Дежурные",
"aria.unavailable": "Недоступен",
"aria.vacation": "Отпуск",
"aria.day_info": "Информация о дне",
"event_type.duty": "Дежурство",
"event_type.unavailable": "Недоступен",
"event_type.vacation": "Отпуск",
"duty.now_on_duty": "Сейчас дежурит",
"duty.none_this_month": "В этом месяце дежурств нет.",
"duty.today": "Сегодня",
"duty.until": "до {time}",
"hint.from": "с",
"hint.to": "до",
"hint.duty_title": "Дежурство:",
"hint.events": "События:"
}
};
const MONTH_KEYS = [
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun",
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec"
];
const WEEKDAY_KEYS = [
"weekdays.mon", "weekdays.tue", "weekdays.wed", "weekdays.thu",
"weekdays.fri", "weekdays.sat", "weekdays.sun"
];
/**
* Normalize language code to 'ru' or 'en'.
* @param {string} code - e.g. 'ru', 'en', 'uk'
* @returns {'ru'|'en'}
*/
function normalizeLang(code) {
if (!code || typeof code !== "string") return "ru";
const lower = code.toLowerCase();
if (lower.startsWith("ru")) return "ru";
return "en";
}
/**
* Detect language: Telegram initData user.language_code → navigator → fallback 'ru'.
* @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);
}
/**
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
* @param {'ru'|'en'} lang
* @param {string} key - e.g. 'app.title', 'duty.until'
* @param {Record<string, string>} [params] - e.g. { time: '14:00' }
* @returns {string}
*/
export function t(lang, key, params = {}) {
const dict = MESSAGES[lang] || MESSAGES.en;
let s = dict[key];
if (s === undefined) s = MESSAGES.en[key];
if (s === undefined) return key;
Object.keys(params).forEach((k) => {
s = s.replace(new RegExp("\\{" + k + "\\}", "g"), params[k]);
});
return s;
}
/**
* Get month name by 0-based index.
* @param {'ru'|'en'} lang
* @param {number} month - 011
* @returns {string}
*/
export function monthName(lang, month) {
const key = MONTH_KEYS[month];
return key ? t(lang, key) : "";
}
/**
* Get weekday short labels (MonSun order) for given lang.
* @param {'ru'|'en'} lang
* @returns {string[]}
*/
export function weekdayLabels(lang) {
return WEEKDAY_KEYS.map((k) => t(lang, k));
}

View File

@@ -3,6 +3,7 @@
*/
import { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js";
import { getInitData } from "./auth.js";
import { isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
@@ -12,7 +13,8 @@ import {
prevBtn,
nextBtn,
loadingEl,
errorEl
errorEl,
weekdaysEl
} from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -32,6 +34,20 @@ import {
initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
if (loadingEl) loadingEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
/**
* Run callback when Telegram WebApp is ready (or immediately outside Telegram).
* Expands and applies theme when in TWA.
@@ -79,12 +95,12 @@ function requireTelegramOrLocalhost(onAllowed) {
onAllowed();
return;
}
showAccessDenied();
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied();
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
}
@@ -151,7 +167,7 @@ async function loadMonth() {
}
return;
}
showError(e.message || "Не удалось загрузить данные.");
showError(e.message || t(state.lang, "error_generic"));
setNavEnabled(true);
return;
}

View File

@@ -3,6 +3,7 @@
*/
import {
state,
calendarEl,
dutyListEl,
loadingEl,
@@ -13,6 +14,7 @@ import {
prevBtn,
nextBtn
} from "./dom.js";
import { t } from "./i18n.js";
/**
* Show access-denied view and hide calendar/list/loading/error.
@@ -26,7 +28,8 @@ export function showAccessDenied(serverDetail) {
if (loadingEl) loadingEl.classList.add("hidden");
if (errorEl) errorEl.hidden = true;
if (accessDeniedEl) {
accessDeniedEl.innerHTML = "<p>Доступ запрещён.</p>";
const msg = t(state.lang, "access_denied");
accessDeniedEl.innerHTML = "<p>" + msg + "</p>";
if (serverDetail && serverDetail.trim()) {
const detail = document.createElement("p");
detail.className = "access-denied-detail";