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

@@ -14,8 +14,49 @@ import {
formatDateKey
} from "./dateUtils.js";
/**
* Build HTML for contact links (phone, Telegram) for a duty. Returns empty string if none.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function dutyCardContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<a href="' + safeHref + '" class="duty-contact-link duty-contact-phone">' +
escapeHtml(p) + "</a>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<a href="' + href.replace(/"/g, "&quot;") + '" class="duty-contact-link duty-contact-username" target="_blank" rel="noopener noreferrer">@' +
escapeHtml(u) + "</a>"
);
}
}
return parts.length
? '<div class="duty-contact-row">' + parts.join(" · ") + "</div>"
: "";
}
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Back/arrow icon SVG for flip button (back to card). */
const ICON_BACK =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
/**
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM HH:MM" or multi-day.
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
* Otherwise returns a plain card without flip wrapper.
* @param {object} d - Duty
* @param {boolean} isCurrent - Whether this is "current" duty
* @returns {string}
@@ -38,15 +79,62 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = dutyCardContactHtml(lang, d);
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
);
if (!hasContacts) {
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
extraClass +
'"><span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
"</div></div>"
);
}
const showLabel = t(lang, "contact.show");
const backLabel = t(lang, "contact.back");
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
'<div class="duty-flip-card' +
extraClass +
'"><span class="duty-item-type">' +
'" data-flipped="false">' +
'<div class="duty-flip-inner">' +
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
'</div>' +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(showLabel) +
'">' +
ICON_PHONE +
"</button>" +
"</div>" +
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="name">' +
escapeHtml(d.full_name) +
"</span>" +
contactHtml +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(backLabel) +
'">' +
ICON_BACK +
"</button>" +
"</div>" +
"</div></div>"
);
}
@@ -91,12 +179,27 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
);
}
/** Whether the delegated flip-button click listener has been attached to dutyListEl. */
let flipListenerAttached = false;
/**
* Render duty list (timeline) for current month; scroll to today if visible.
* @param {object[]} duties - Duties (only duty type used for timeline)
*/
export function renderDutyList(duties) {
if (!dutyListEl) return;
if (!flipListenerAttached) {
flipListenerAttached = true;
dutyListEl.addEventListener("click", (e) => {
const btn = e.target.closest(".duty-flip-btn");
if (!btn) return;
const card = btn.closest(".duty-flip-card");
if (!card) return;
const flipped = card.getAttribute("data-flipped") === "true";
card.setAttribute("data-flipped", String(!flipped));
});
}
const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline");