feat: enhance current duty display with remaining time and improved contact links
- Added a new message key for displaying remaining time until the end of the shift in both English and Russian. - Updated the current duty card to show remaining time with a formatted string. - Enhanced the contact links to support block layout with icons for phone and Telegram, improving visual presentation. - Implemented a new utility function to calculate remaining time until the end of the shift. - Added unit tests for the new functionality, ensuring accurate time calculations and proper rendering of contact links.
This commit is contained in:
@@ -93,6 +93,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar",
|
||||
},
|
||||
"ru": {
|
||||
@@ -175,6 +176,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -70,10 +70,23 @@
|
||||
.current-duty-card {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
border-top: 3px solid var(--duty);
|
||||
padding: 24px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: card-appear 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes card-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-title {
|
||||
@@ -83,6 +96,39 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.current-duty-live-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--duty);
|
||||
margin-right: 8px;
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.current-duty-card {
|
||||
animation: none;
|
||||
}
|
||||
.current-duty-live-dot {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.5rem;
|
||||
@@ -96,10 +142,50 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.current-duty-no-duty,
|
||||
.current-duty-remaining {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* No-duty state: icon + prominent text */
|
||||
.current-duty-card--no-duty {
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
.current-duty-no-duty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.current-duty-no-duty-icon {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.current-duty-no-duty-icon svg {
|
||||
display: block;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.current-duty-no-duty {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
max-width: 20em;
|
||||
}
|
||||
|
||||
.current-duty-error {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--muted);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.current-duty-error {
|
||||
@@ -110,6 +196,43 @@
|
||||
margin: 12px 0 20px 0;
|
||||
}
|
||||
|
||||
.current-duty-contact-row--blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.current-duty-contact-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.current-duty-contact-block:hover,
|
||||
.current-duty-contact-block:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.current-duty-contact-block:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.current-duty-contact-block svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.current-duty-contact {
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
|
||||
@@ -6,6 +6,35 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
|
||||
/** Phone icon SVG for block layout (inline, same style as dutyList). */
|
||||
const ICON_PHONE =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>';
|
||||
|
||||
/** Telegram / send icon SVG for block layout. */
|
||||
const ICON_TELEGRAM =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||
|
||||
/**
|
||||
* Format Russian phone number for display: 79146522209 -> +7 914 652-22-09.
|
||||
* Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX).
|
||||
* Other lengths are returned as-is (digits only).
|
||||
*
|
||||
* @param {string} phone - Raw phone string (digits, optional leading 7/8)
|
||||
* @returns {string} Formatted display string, e.g. "+7 914 652-22-09"
|
||||
*/
|
||||
export function formatPhoneDisplay(phone) {
|
||||
if (phone == null || String(phone).trim() === "") return "";
|
||||
const digits = String(phone).replace(/\D/g, "");
|
||||
if (digits.length === 10) {
|
||||
return "+7 " + digits.slice(0, 3) + " " + digits.slice(3, 6) + "-" + digits.slice(6, 8) + "-" + digits.slice(8);
|
||||
}
|
||||
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
|
||||
const rest = digits.slice(1);
|
||||
return "+7 " + rest.slice(0, 3) + " " + rest.slice(3, 6) + "-" + rest.slice(6, 8) + "-" + rest.slice(8);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML for contact links (phone, Telegram username).
|
||||
* Validates phone/username, builds tel: and t.me hrefs, wraps in spans/links.
|
||||
@@ -15,12 +44,13 @@ import { escapeHtml } from "./utils.js";
|
||||
* @param {string|null|undefined} username - Telegram username with or without leading @
|
||||
* @param {object} options - Rendering options
|
||||
* @param {string} options.classPrefix - CSS class prefix (e.g. "day-detail-contact", "duty-contact")
|
||||
* @param {boolean} [options.showLabels=true] - Whether to show "Phone:" / "Telegram:" labels
|
||||
* @param {boolean} [options.showLabels=true] - Whether to show "Phone:" / "Telegram:" labels (ignored when layout is "block")
|
||||
* @param {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ")
|
||||
* @param {'inline'|'block'} [options.layout='inline'] - "block" = full-width button blocks with SVG icons (for current duty card)
|
||||
* @returns {string} HTML string or "" if no valid contact
|
||||
*/
|
||||
export function buildContactLinksHtml(lang, phone, username, options) {
|
||||
const { classPrefix, showLabels = true, separator = " " } = options || {};
|
||||
const { classPrefix, showLabels = true, separator = " ", layout = "inline" } = options || {};
|
||||
const parts = [];
|
||||
|
||||
if (phone && String(phone).trim()) {
|
||||
@@ -28,45 +58,31 @@ export function buildContactLinksHtml(lang, phone, username, options) {
|
||||
const safeHref =
|
||||
"tel:" +
|
||||
p.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
const linkHtml =
|
||||
'<a href="' +
|
||||
safeHref +
|
||||
'" class="' +
|
||||
escapeHtml(classPrefix + "-link " + classPrefix + "-phone") +
|
||||
'">' +
|
||||
escapeHtml(p) +
|
||||
"</a>";
|
||||
if (showLabels) {
|
||||
const label = t(lang, "contact.phone");
|
||||
const displayPhone = formatPhoneDisplay(p);
|
||||
if (layout === "block") {
|
||||
const blockClass = classPrefix + "-block " + classPrefix + "-block--phone";
|
||||
parts.push(
|
||||
'<span class="' +
|
||||
escapeHtml(classPrefix) +
|
||||
'<a href="' +
|
||||
safeHref +
|
||||
'" class="' +
|
||||
escapeHtml(blockClass) +
|
||||
'">' +
|
||||
escapeHtml(label) +
|
||||
": " +
|
||||
linkHtml +
|
||||
"</span>"
|
||||
ICON_PHONE +
|
||||
"<span>" +
|
||||
escapeHtml(displayPhone) +
|
||||
"</span></a>"
|
||||
);
|
||||
} else {
|
||||
parts.push(linkHtml);
|
||||
}
|
||||
}
|
||||
|
||||
if (username && String(username).trim()) {
|
||||
const u = String(username).trim().replace(/^@+/, "");
|
||||
if (u) {
|
||||
const display = "@" + u;
|
||||
const href = "https://t.me/" + encodeURIComponent(u);
|
||||
const linkHtml =
|
||||
'<a href="' +
|
||||
escapeHtml(href) +
|
||||
safeHref +
|
||||
'" class="' +
|
||||
escapeHtml(classPrefix + "-link " + classPrefix + "-username") +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
escapeHtml(display) +
|
||||
escapeHtml(classPrefix + "-link " + classPrefix + "-phone") +
|
||||
'">' +
|
||||
escapeHtml(displayPhone) +
|
||||
"</a>";
|
||||
if (showLabels) {
|
||||
const label = t(lang, "contact.telegram");
|
||||
const label = t(lang, "contact.phone");
|
||||
parts.push(
|
||||
'<span class="' +
|
||||
escapeHtml(classPrefix) +
|
||||
@@ -82,7 +98,53 @@ export function buildContactLinksHtml(lang, phone, username, options) {
|
||||
}
|
||||
}
|
||||
|
||||
if (username && String(username).trim()) {
|
||||
const u = String(username).trim().replace(/^@+/, "");
|
||||
if (u) {
|
||||
const display = "@" + u;
|
||||
const href = "https://t.me/" + encodeURIComponent(u);
|
||||
if (layout === "block") {
|
||||
const blockClass = classPrefix + "-block " + classPrefix + "-block--telegram";
|
||||
parts.push(
|
||||
'<a href="' +
|
||||
escapeHtml(href) +
|
||||
'" class="' +
|
||||
escapeHtml(blockClass) +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
ICON_TELEGRAM +
|
||||
"<span>" +
|
||||
escapeHtml(display) +
|
||||
"</span></a>"
|
||||
);
|
||||
} else {
|
||||
const linkHtml =
|
||||
'<a href="' +
|
||||
escapeHtml(href) +
|
||||
'" class="' +
|
||||
escapeHtml(classPrefix + "-link " + classPrefix + "-username") +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
escapeHtml(display) +
|
||||
"</a>";
|
||||
if (showLabels) {
|
||||
const label = t(lang, "contact.telegram");
|
||||
parts.push(
|
||||
'<span class="' +
|
||||
escapeHtml(classPrefix) +
|
||||
'">' +
|
||||
escapeHtml(label) +
|
||||
": " +
|
||||
linkHtml +
|
||||
"</span>"
|
||||
);
|
||||
} else {
|
||||
parts.push(linkHtml);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return "";
|
||||
const rowClass = classPrefix + "-row";
|
||||
return '<div class="' + escapeHtml(rowClass) + '">' + parts.join(separator) + "</div>";
|
||||
const rowClass = classPrefix + "-row" + (layout === "block" ? " " + classPrefix + "-row--blocks" : "");
|
||||
const inner = layout === "block" ? parts.join("") : parts.join(separator);
|
||||
return '<div class="' + escapeHtml(rowClass) + '">' + inner + "</div>";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
/**
|
||||
* Unit tests for buildContactLinksHtml (contact links HTML builder).
|
||||
* Unit tests for contactHtml (formatPhoneDisplay, buildContactLinksHtml).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||
import { formatPhoneDisplay, buildContactLinksHtml } from "./contactHtml.js";
|
||||
|
||||
describe("formatPhoneDisplay", () => {
|
||||
it("formats 11-digit number starting with 7", () => {
|
||||
expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09");
|
||||
expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("formats 11-digit number starting with 8", () => {
|
||||
expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("formats 10-digit number as Russian", () => {
|
||||
expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("returns empty string for null or empty", () => {
|
||||
expect(formatPhoneDisplay(null)).toBe("");
|
||||
expect(formatPhoneDisplay("")).toBe("");
|
||||
expect(formatPhoneDisplay(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("strips non-digits before formatting", () => {
|
||||
expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("returns digits as-is for non-10/11 length", () => {
|
||||
expect(formatPhoneDisplay("123")).toBe("123");
|
||||
expect(formatPhoneDisplay("12345678901")).toBe("12345678901");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContactLinksHtml", () => {
|
||||
const baseOptions = { classPrefix: "test-contact", showLabels: true, separator: " " };
|
||||
@@ -24,6 +54,12 @@ describe("buildContactLinksHtml", () => {
|
||||
expect(html).not.toContain("t.me");
|
||||
});
|
||||
|
||||
it("displays phone formatted for Russian numbers", () => {
|
||||
const html = buildContactLinksHtml("en", "79146522209", null, baseOptions);
|
||||
expect(html).toContain("+7 914 652-22-09");
|
||||
expect(html).toContain('href="tel:79146522209"');
|
||||
});
|
||||
|
||||
it("renders username only with label and t.me link", () => {
|
||||
const html = buildContactLinksHtml("en", null, "alice_dev", baseOptions);
|
||||
expect(html).toContain("test-contact-row");
|
||||
@@ -39,6 +75,7 @@ describe("buildContactLinksHtml", () => {
|
||||
expect(html).toContain("test-contact-row");
|
||||
expect(html).toContain("tel:");
|
||||
expect(html).toContain("+79001112233");
|
||||
expect(html).toContain("+7 900 111-22-33");
|
||||
expect(html).toContain("t.me");
|
||||
expect(html).toContain("@bob");
|
||||
expect(html).toContain("Phone");
|
||||
@@ -58,12 +95,12 @@ describe("buildContactLinksHtml", () => {
|
||||
expect(html).toContain("@user");
|
||||
});
|
||||
|
||||
it("escapes special characters in phone in href and text", () => {
|
||||
it("escapes special characters in phone href; display uses formatted digits only", () => {
|
||||
const html = buildContactLinksHtml("en", '+7 999 "1" <2>', null, baseOptions);
|
||||
expect(html).toContain(""");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain(">");
|
||||
expect(html).toContain("tel:");
|
||||
expect(html).toContain("799912");
|
||||
expect(html).not.toContain("<2>");
|
||||
expect(html).not.toContain('"1"');
|
||||
});
|
||||
@@ -97,4 +134,43 @@ describe("buildContactLinksHtml", () => {
|
||||
expect(html).toContain("minimal-row");
|
||||
expect(html).not.toContain(" · ");
|
||||
});
|
||||
|
||||
describe("layout: block", () => {
|
||||
it("renders phone as block with icon and formatted number", () => {
|
||||
const html = buildContactLinksHtml("en", "79146522209", null, {
|
||||
classPrefix: "current-duty-contact",
|
||||
layout: "block",
|
||||
});
|
||||
expect(html).toContain("current-duty-contact-row--blocks");
|
||||
expect(html).toContain("current-duty-contact-block");
|
||||
expect(html).toContain("current-duty-contact-block--phone");
|
||||
expect(html).toContain("+7 914 652-22-09");
|
||||
expect(html).toContain("tel:");
|
||||
expect(html).toContain("<svg");
|
||||
expect(html).not.toContain("Phone");
|
||||
});
|
||||
|
||||
it("renders telegram as block with icon and @username", () => {
|
||||
const html = buildContactLinksHtml("en", null, "alice_dev", {
|
||||
classPrefix: "current-duty-contact",
|
||||
layout: "block",
|
||||
});
|
||||
expect(html).toContain("current-duty-contact-block--telegram");
|
||||
expect(html).toContain("https://t.me/");
|
||||
expect(html).toContain("@alice_dev");
|
||||
expect(html).toContain("<svg");
|
||||
expect(html).not.toContain("Telegram");
|
||||
});
|
||||
|
||||
it("renders both phone and telegram as stacked blocks", () => {
|
||||
const html = buildContactLinksHtml("en", "+79001112233", "bob", {
|
||||
classPrefix: "current-duty-contact",
|
||||
layout: "block",
|
||||
});
|
||||
expect(html).toContain("current-duty-contact-block--phone");
|
||||
expect(html).toContain("current-duty-contact-block--telegram");
|
||||
expect(html).toContain("+7 900 111-22-33");
|
||||
expect(html).toContain("@bob");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,29 @@ import {
|
||||
formatHHMM
|
||||
} from "./dateUtils.js";
|
||||
|
||||
/** Empty calendar icon for "no duty" state (outline, stroke). */
|
||||
const ICON_NO_DUTY =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
|
||||
/** @type {(() => void)|null} Callback when user taps "Back to calendar". */
|
||||
let onBackCallback = null;
|
||||
/** @type {(() => void)|null} Handler registered with Telegram BackButton.onClick. */
|
||||
let backButtonHandler = null;
|
||||
|
||||
/**
|
||||
* Compute remaining time until end of shift. Call only when now < end (active duty).
|
||||
* @param {string|Date} endAt - ISO end time of the shift
|
||||
* @returns {{ hours: number, minutes: number }}
|
||||
*/
|
||||
export function getRemainingTime(endAt) {
|
||||
const end = new Date(endAt).getTime();
|
||||
const now = Date.now();
|
||||
const ms = Math.max(0, end - now);
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -49,13 +67,18 @@ export function renderCurrentDutyContent(duty, lang) {
|
||||
if (!duty) {
|
||||
const noDuty = t(lang, "current_duty.no_duty");
|
||||
return (
|
||||
'<div class="current-duty-card">' +
|
||||
'<div class="current-duty-card current-duty-card--no-duty">' +
|
||||
'<h2 class="current-duty-title">' +
|
||||
escapeHtml(title) +
|
||||
"</h2>" +
|
||||
'<div class="current-duty-no-duty-wrap">' +
|
||||
'<span class="current-duty-no-duty-icon">' +
|
||||
ICON_NO_DUTY +
|
||||
"</span>" +
|
||||
'<p class="current-duty-no-duty">' +
|
||||
escapeHtml(noDuty) +
|
||||
"</p>" +
|
||||
"</div>" +
|
||||
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
||||
escapeHtml(backLabel) +
|
||||
"</button>" +
|
||||
@@ -78,15 +101,22 @@ export function renderCurrentDutyContent(duty, lang) {
|
||||
" " +
|
||||
endTime;
|
||||
const shiftLabel = t(lang, "current_duty.shift");
|
||||
const { hours: remHours, minutes: remMinutes } = getRemainingTime(duty.end_at);
|
||||
const remainingStr = t(lang, "current_duty.remaining", {
|
||||
hours: String(remHours),
|
||||
minutes: String(remMinutes)
|
||||
});
|
||||
const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, {
|
||||
classPrefix: "current-duty-contact",
|
||||
showLabels: true,
|
||||
separator: " "
|
||||
separator: " ",
|
||||
layout: "block"
|
||||
});
|
||||
|
||||
return (
|
||||
'<div class="current-duty-card">' +
|
||||
'<h2 class="current-duty-title">' +
|
||||
'<span class="current-duty-live-dot"></span> ' +
|
||||
escapeHtml(title) +
|
||||
"</h2>" +
|
||||
'<p class="current-duty-name">' +
|
||||
@@ -97,6 +127,9 @@ export function renderCurrentDutyContent(duty, lang) {
|
||||
": " +
|
||||
escapeHtml(shiftStr) +
|
||||
"</div>" +
|
||||
'<div class="current-duty-remaining">' +
|
||||
escapeHtml(remainingStr) +
|
||||
"</div>" +
|
||||
contactHtml +
|
||||
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
||||
escapeHtml(backLabel) +
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("./api.js", () => ({
|
||||
|
||||
import {
|
||||
findCurrentDuty,
|
||||
getRemainingTime,
|
||||
renderCurrentDutyContent
|
||||
} from "./currentDuty.js";
|
||||
|
||||
@@ -24,6 +25,28 @@ describe("currentDuty", () => {
|
||||
"</div>";
|
||||
});
|
||||
|
||||
describe("getRemainingTime", () => {
|
||||
it("returns hours and minutes until end from now", () => {
|
||||
const endAt = "2025-03-02T17:30:00.000Z";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
|
||||
const { hours, minutes } = getRemainingTime(endAt);
|
||||
vi.useRealTimers();
|
||||
expect(hours).toBe(5);
|
||||
expect(minutes).toBe(30);
|
||||
});
|
||||
|
||||
it("returns 0 when end is in the past", () => {
|
||||
const endAt = "2025-03-02T09:00:00.000Z";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
|
||||
const { hours, minutes } = getRemainingTime(endAt);
|
||||
vi.useRealTimers();
|
||||
expect(hours).toBe(0);
|
||||
expect(minutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCurrentDuty", () => {
|
||||
it("returns duty when now is between start_at and end_at", () => {
|
||||
const now = new Date();
|
||||
@@ -67,13 +90,18 @@ describe("currentDuty", () => {
|
||||
it("renders no-duty message and back button when duty is null", () => {
|
||||
const html = renderCurrentDutyContent(null, "en");
|
||||
expect(html).toContain("current-duty-card");
|
||||
expect(html).toContain("current-duty-card--no-duty");
|
||||
expect(html).toContain("Current Duty");
|
||||
expect(html).toContain("current-duty-no-duty-wrap");
|
||||
expect(html).toContain("current-duty-no-duty-icon");
|
||||
expect(html).toContain("current-duty-no-duty");
|
||||
expect(html).toContain("No one is on duty right now");
|
||||
expect(html).toContain("Back to calendar");
|
||||
expect(html).toContain('data-action="back"');
|
||||
expect(html).not.toContain("current-duty-live-dot");
|
||||
});
|
||||
|
||||
it("renders duty card with name, shift, and back button when duty has no contacts", () => {
|
||||
it("renders duty card with name, shift, remaining time, and back button when duty has no contacts", () => {
|
||||
const duty = {
|
||||
event_type: "duty",
|
||||
full_name: "Иванов Иван",
|
||||
@@ -81,9 +109,12 @@ describe("currentDuty", () => {
|
||||
end_at: "2025-03-03T06:00:00.000Z"
|
||||
};
|
||||
const html = renderCurrentDutyContent(duty, "ru");
|
||||
expect(html).toContain("current-duty-live-dot");
|
||||
expect(html).toContain("Текущее дежурство");
|
||||
expect(html).toContain("Иванов Иван");
|
||||
expect(html).toContain("Смена");
|
||||
expect(html).toContain("current-duty-remaining");
|
||||
expect(html).toMatch(/Осталось:\s*\d+ч\s*\d+мин/);
|
||||
expect(html).toContain("Назад к календарю");
|
||||
expect(html).toContain('data-action="back"');
|
||||
});
|
||||
@@ -99,7 +130,11 @@ describe("currentDuty", () => {
|
||||
};
|
||||
const html = renderCurrentDutyContent(duty, "en");
|
||||
expect(html).toContain("Alice");
|
||||
expect(html).toContain("current-duty-remaining");
|
||||
expect(html).toMatch(/Remaining:\s*\d+h\s*\d+min/);
|
||||
expect(html).toContain("current-duty-contact-row");
|
||||
expect(html).toContain("current-duty-contact-row--blocks");
|
||||
expect(html).toContain("current-duty-contact-block");
|
||||
expect(html).toContain('href="tel:');
|
||||
expect(html).toContain("+7 900 123-45-67");
|
||||
expect(html).toContain("https://t.me/");
|
||||
|
||||
@@ -58,6 +58,7 @@ export const MESSAGES = {
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar"
|
||||
},
|
||||
ru: {
|
||||
@@ -112,6 +113,7 @@ export const MESSAGES = {
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю"
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user