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:
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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, """) + '" 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");
|
||||
|
||||
Reference in New Issue
Block a user