- Added a new message key for displaying remaining time until the end of the shift in both English and Russian. - Updated the current duty card to show remaining time with a formatted string. - Enhanced the contact links to support block layout with icons for phone and Telegram, improving visual presentation. - Implemented a new utility function to calculate remaining time until the end of the shift. - Added unit tests for the new functionality, ensuring accurate time calculations and proper rendering of contact links.
203 lines
6.5 KiB
JavaScript
203 lines
6.5 KiB
JavaScript
/**
|
||
* 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:",
|
||
"day_detail.close": "Close",
|
||
"contact.label": "Contact",
|
||
"contact.show": "Contacts",
|
||
"contact.back": "Back",
|
||
"contact.phone": "Phone",
|
||
"contact.telegram": "Telegram",
|
||
"current_duty.title": "Current Duty",
|
||
"current_duty.no_duty": "No one is on duty right now",
|
||
"current_duty.shift": "Shift",
|
||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||
"current_duty.back": "Back to calendar"
|
||
},
|
||
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": "События:",
|
||
"day_detail.close": "Закрыть",
|
||
"contact.label": "Контакт",
|
||
"contact.show": "Контакты",
|
||
"contact.back": "Назад",
|
||
"contact.phone": "Телефон",
|
||
"contact.telegram": "Telegram",
|
||
"current_duty.title": "Текущее дежурство",
|
||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||
"current_duty.shift": "Смена",
|
||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||
"current_duty.back": "Назад к календарю"
|
||
}
|
||
};
|
||
|
||
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 - 0–11
|
||
* @returns {string}
|
||
*/
|
||
export function monthName(lang, month) {
|
||
const key = MONTH_KEYS[month];
|
||
return key ? t(lang, key) : "";
|
||
}
|
||
|
||
/**
|
||
* Get weekday short labels (Mon–Sun order) for given lang.
|
||
* @param {'ru'|'en'} lang
|
||
* @returns {string[]}
|
||
*/
|
||
export function weekdayLabels(lang) {
|
||
return WEEKDAY_KEYS.map((k) => t(lang, k));
|
||
}
|