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:
2026-03-02 16:09:08 +03:00
parent f8aceabab5
commit e3240d0981
25 changed files with 1126 additions and 44 deletions

View File

@@ -35,6 +35,43 @@ function parseDataAttr(raw) {
}
}
/**
* Build HTML for contact info (phone link, Telegram username link) for a duty entry.
* @param {'ru'|'en'} lang
* @param {string|null|undefined} phone
* @param {string|null|undefined} username - Telegram username with or without leading @
* @returns {string}
*/
function buildContactHtml(lang, phone, username) {
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const label = t(lang, "contact.phone");
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + safeHref + '" class="day-detail-contact-link day-detail-contact-phone">' +
escapeHtml(p) + "</a></span>"
);
}
if (username && String(username).trim()) {
const u = String(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="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + escapeHtml(href) + '" class="day-detail-contact-link day-detail-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) + "</a></span>"
);
}
}
return parts.length ? '<div class="day-detail-contact-row">' + parts.join(" ") + "</div>" : "";
}
/**
* Build HTML content for the day detail panel.
* @param {string} dateKey - YYYY-MM-DD
@@ -72,7 +109,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
);
const rows = hasTimes
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" }));
: dutyList.map((it) => ({
timePrefix: "",
fullName: it.full_name || "",
phone: it.phone,
username: it.username
}));
html +=
'<section class="day-detail-section day-detail-section--duty">' +
@@ -80,12 +122,17 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
escapeHtml(t(lang, "event_type.duty")) +
"</h3><ul class=" +
'"day-detail-list">';
rows.forEach((r) => {
rows.forEach((r, i) => {
const duty = hasTimes ? dutyList[i] : null;
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactHtml(lang, phone, username);
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) +
(contactHtml ? contactHtml : "") +
"</li>";
});
html += "</ul></section>";