feat: enhance duty information handling with contact details and current duty view
- 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.
This commit is contained in:
239
webapp/js/currentDuty.js
Normal file
239
webapp/js/currentDuty.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user