feat: enhance current duty display with remaining time and improved contact links
All checks were successful
CI / lint-and-test (push) Successful in 35s
Docker Build and Release / build-and-push (push) Successful in 51s
Docker Build and Release / release (push) Successful in 8s

- 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:
2026-03-02 19:04:30 +03:00
parent 2e78b3c1e6
commit 0d28123d0b
7 changed files with 376 additions and 43 deletions

View File

@@ -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": "Назад к календарю",
},
}

View File

@@ -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;

View File

@@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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>";
}

View File

@@ -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("&quot;");
expect(html).toContain("&lt;");
expect(html).toContain("&gt;");
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");
});
});
});

View File

@@ -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) +

View File

@@ -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/");

View File

@@ -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": "Назад к календарю"
}
};