- Added `bot_username` to settings for dynamic retrieval of the bot's username. - Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats. - Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information. - Enhanced API responses to include contact details for users, ensuring better communication. - Introduced a new current duty view in the web app, displaying active duty information along with contact options. - Updated CSS styles for better presentation of contact information in duty cards. - Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
240 lines
7.7 KiB
JavaScript
240 lines
7.7 KiB
JavaScript
/**
|
|
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
|
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
|
|
*/
|
|
|
|
import { currentDutyViewEl, state, loadingEl } from "./dom.js";
|
|
import { t } from "./i18n.js";
|
|
import { escapeHtml } from "./utils.js";
|
|
import { fetchDuties } from "./api.js";
|
|
import {
|
|
localDateString,
|
|
dateKeyToDDMM,
|
|
formatHHMM
|
|
} from "./dateUtils.js";
|
|
|
|
/**
|
|
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
|
|
* @param {object[]} duties - List of duties with start_at, end_at, event_type
|
|
* @returns {object|null}
|
|
*/
|
|
export function findCurrentDuty(duties) {
|
|
const now = Date.now();
|
|
const dutyType = (duties || []).filter((d) => d.event_type === "duty");
|
|
const candidates = dutyType.length ? dutyType : duties || [];
|
|
for (const d of candidates) {
|
|
const start = new Date(d.start_at).getTime();
|
|
const end = new Date(d.end_at).getTime();
|
|
if (start <= now && now < end) return d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build contact HTML (phone + Telegram) for current duty card, styled like day-detail.
|
|
* @param {'ru'|'en'} lang
|
|
* @param {object} d - Duty with optional phone, username
|
|
* @returns {string}
|
|
*/
|
|
function buildContactHtml(lang, d) {
|
|
const parts = [];
|
|
if (d.phone && String(d.phone).trim()) {
|
|
const p = String(d.phone).trim();
|
|
const label = t(lang, "contact.phone");
|
|
const safeHref =
|
|
"tel:" +
|
|
p.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
parts.push(
|
|
'<span class="current-duty-contact">' +
|
|
escapeHtml(label) +
|
|
": " +
|
|
'<a href="' +
|
|
safeHref +
|
|
'" class="current-duty-contact-link current-duty-contact-phone">' +
|
|
escapeHtml(p) +
|
|
"</a></span>"
|
|
);
|
|
}
|
|
if (d.username && String(d.username).trim()) {
|
|
const u = String(d.username).trim().replace(/^@+/, "");
|
|
if (u) {
|
|
const label = t(lang, "contact.telegram");
|
|
const display = "@" + u;
|
|
const href = "https://t.me/" + encodeURIComponent(u);
|
|
parts.push(
|
|
'<span class="current-duty-contact">' +
|
|
escapeHtml(label) +
|
|
": " +
|
|
'<a href="' +
|
|
escapeHtml(href) +
|
|
'" class="current-duty-contact-link current-duty-contact-username" target="_blank" rel="noopener noreferrer">' +
|
|
escapeHtml(display) +
|
|
"</a></span>"
|
|
);
|
|
}
|
|
}
|
|
return parts.length
|
|
? '<div class="current-duty-contact-row">' + parts.join(" ") + "</div>"
|
|
: "";
|
|
}
|
|
|
|
/**
|
|
* Render the current duty view content (card with duty or no-duty message).
|
|
* @param {object|null} duty - Active duty or null
|
|
* @param {string} lang
|
|
* @returns {string}
|
|
*/
|
|
export function renderCurrentDutyContent(duty, lang) {
|
|
const backLabel = t(lang, "current_duty.back");
|
|
const title = t(lang, "current_duty.title");
|
|
|
|
if (!duty) {
|
|
const noDuty = t(lang, "current_duty.no_duty");
|
|
return (
|
|
'<div class="current-duty-card">' +
|
|
'<h2 class="current-duty-title">' +
|
|
escapeHtml(title) +
|
|
"</h2>" +
|
|
'<p class="current-duty-no-duty">' +
|
|
escapeHtml(noDuty) +
|
|
"</p>" +
|
|
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
|
escapeHtml(backLabel) +
|
|
"</button>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
const startLocal = localDateString(new Date(duty.start_at));
|
|
const endLocal = localDateString(new Date(duty.end_at));
|
|
const startDDMM = dateKeyToDDMM(startLocal);
|
|
const endDDMM = dateKeyToDDMM(endLocal);
|
|
const startTime = formatHHMM(duty.start_at);
|
|
const endTime = formatHHMM(duty.end_at);
|
|
const shiftStr =
|
|
startDDMM +
|
|
" " +
|
|
startTime +
|
|
" — " +
|
|
endDDMM +
|
|
" " +
|
|
endTime;
|
|
const shiftLabel = t(lang, "current_duty.shift");
|
|
const contactHtml = buildContactHtml(lang, duty);
|
|
|
|
return (
|
|
'<div class="current-duty-card">' +
|
|
'<h2 class="current-duty-title">' +
|
|
escapeHtml(title) +
|
|
"</h2>" +
|
|
'<p class="current-duty-name">' +
|
|
escapeHtml(duty.full_name) +
|
|
"</p>" +
|
|
'<div class="current-duty-shift">' +
|
|
escapeHtml(shiftLabel) +
|
|
": " +
|
|
escapeHtml(shiftStr) +
|
|
"</div>" +
|
|
contactHtml +
|
|
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
|
escapeHtml(backLabel) +
|
|
"</button>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Show the current duty view: fetch today's duties, render card or no-duty, show back button.
|
|
* Hides calendar/duty list and shows #currentDutyView. Optionally shows Telegram BackButton.
|
|
* @param {() => void} onBack - Callback when user taps "Back to calendar"
|
|
*/
|
|
export async function showCurrentDutyView(onBack) {
|
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
|
const calendarSticky = document.getElementById("calendarSticky");
|
|
const dutyList = document.getElementById("dutyList");
|
|
if (!currentDutyViewEl) return;
|
|
|
|
currentDutyViewEl._onBack = onBack;
|
|
currentDutyViewEl.classList.remove("hidden");
|
|
if (container) container.setAttribute("data-view", "currentDuty");
|
|
if (calendarSticky) calendarSticky.hidden = true;
|
|
if (dutyList) dutyList.hidden = true;
|
|
if (loadingEl) loadingEl.classList.add("hidden");
|
|
|
|
const lang = state.lang;
|
|
currentDutyViewEl.innerHTML =
|
|
'<div class="current-duty-loading">' +
|
|
escapeHtml(t(lang, "loading")) +
|
|
"</div>";
|
|
|
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
|
window.Telegram.WebApp.BackButton.show();
|
|
const handler = () => {
|
|
if (currentDutyViewEl._onBack) currentDutyViewEl._onBack();
|
|
};
|
|
currentDutyViewEl._backButtonHandler = handler;
|
|
window.Telegram.WebApp.BackButton.onClick(handler);
|
|
}
|
|
|
|
const today = new Date();
|
|
const from = localDateString(today);
|
|
const to = from;
|
|
try {
|
|
const duties = await fetchDuties(from, to);
|
|
const duty = findCurrentDuty(duties);
|
|
currentDutyViewEl.innerHTML = renderCurrentDutyContent(duty, lang);
|
|
} catch (e) {
|
|
currentDutyViewEl.innerHTML =
|
|
'<div class="current-duty-card">' +
|
|
'<p class="current-duty-error">' +
|
|
escapeHtml(e.message || t(lang, "error_generic")) +
|
|
"</p>" +
|
|
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
|
escapeHtml(t(lang, "current_duty.back")) +
|
|
"</button>" +
|
|
"</div>";
|
|
}
|
|
|
|
currentDutyViewEl.addEventListener("click", handleCurrentDutyClick);
|
|
}
|
|
|
|
/**
|
|
* Delegate click for back button.
|
|
* @param {MouseEvent} e
|
|
*/
|
|
function handleCurrentDutyClick(e) {
|
|
const btn = e.target && e.target.closest("[data-action='back']");
|
|
if (!btn) return;
|
|
if (currentDutyViewEl && currentDutyViewEl._onBack) {
|
|
currentDutyViewEl._onBack();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the current duty view and show calendar/duty list again.
|
|
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
|
|
*/
|
|
export function hideCurrentDutyView() {
|
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
|
const calendarSticky = document.getElementById("calendarSticky");
|
|
const dutyList = document.getElementById("dutyList");
|
|
const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler;
|
|
|
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
|
if (backHandler) {
|
|
window.Telegram.WebApp.BackButton.offClick(backHandler);
|
|
}
|
|
window.Telegram.WebApp.BackButton.hide();
|
|
}
|
|
if (currentDutyViewEl) {
|
|
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
|
|
currentDutyViewEl._onBack = null;
|
|
currentDutyViewEl._backButtonHandler = null;
|
|
currentDutyViewEl.classList.add("hidden");
|
|
currentDutyViewEl.innerHTML = "";
|
|
}
|
|
if (container) container.removeAttribute("data-view");
|
|
if (calendarSticky) calendarSticky.hidden = false;
|
|
if (dutyList) dutyList.hidden = false;
|
|
}
|