From 0d28123d0b88f6ad8ea1fbd5d2e8c4499c24fb89 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Mon, 2 Mar 2026 19:04:30 +0300 Subject: [PATCH] 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. --- duty_teller/i18n/messages.py | 2 + webapp/css/states.css | 127 ++++++++++++++++++++++++++++++++- webapp/js/contactHtml.js | 130 +++++++++++++++++++++++++--------- webapp/js/contactHtml.test.js | 84 ++++++++++++++++++++-- webapp/js/currentDuty.js | 37 +++++++++- webapp/js/currentDuty.test.js | 37 +++++++++- webapp/js/i18n.js | 2 + 7 files changed, 376 insertions(+), 43 deletions(-) diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index ca485da..2a606d5 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -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": "Назад к календарю", }, } diff --git a/webapp/css/states.css b/webapp/css/states.css index 8273fda..0ed0105 100644 --- a/webapp/css/states.css +++ b/webapp/css/states.css @@ -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; diff --git a/webapp/js/contactHtml.js b/webapp/js/contactHtml.js index 8594c7c..9534417 100644 --- a/webapp/js/contactHtml.js +++ b/webapp/js/contactHtml.js @@ -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 = + ''; + +/** Telegram / send icon SVG for block layout. */ +const ICON_TELEGRAM = + ''; + +/** + * 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(/' + - escapeHtml(p) + - ""; - if (showLabels) { - const label = t(lang, "contact.phone"); + const displayPhone = formatPhoneDisplay(p); + if (layout === "block") { + const blockClass = classPrefix + "-block " + classPrefix + "-block--phone"; parts.push( - '' + - escapeHtml(label) + - ": " + - linkHtml + - "" + ICON_PHONE + + "" + + escapeHtml(displayPhone) + + "" ); } 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 = '' + - escapeHtml(display) + + escapeHtml(classPrefix + "-link " + classPrefix + "-phone") + + '">' + + escapeHtml(displayPhone) + ""; if (showLabels) { - const label = t(lang, "contact.telegram"); + const label = t(lang, "contact.phone"); parts.push( '' + + ICON_TELEGRAM + + "" + + escapeHtml(display) + + "" + ); + } else { + const linkHtml = + '' + + escapeHtml(display) + + ""; + if (showLabels) { + const label = t(lang, "contact.telegram"); + parts.push( + '' + + escapeHtml(label) + + ": " + + linkHtml + + "" + ); + } else { + parts.push(linkHtml); + } + } + } + } + if (parts.length === 0) return ""; - const rowClass = classPrefix + "-row"; - return '
' + parts.join(separator) + "
"; + const rowClass = classPrefix + "-row" + (layout === "block" ? " " + classPrefix + "-row--blocks" : ""); + const inner = layout === "block" ? parts.join("") : parts.join(separator); + return '
' + inner + "
"; } diff --git a/webapp/js/contactHtml.test.js b/webapp/js/contactHtml.test.js index 40a3874..ad0dfc8 100644 --- a/webapp/js/contactHtml.test.js +++ b/webapp/js/contactHtml.test.js @@ -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(" { + 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(" { + 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"); + }); + }); }); diff --git a/webapp/js/currentDuty.js b/webapp/js/currentDuty.js index ee01291..1ae1bca 100644 --- a/webapp/js/currentDuty.js +++ b/webapp/js/currentDuty.js @@ -14,11 +14,29 @@ import { formatHHMM } from "./dateUtils.js"; +/** Empty calendar icon for "no duty" state (outline, stroke). */ +const ICON_NO_DUTY = + ''; + /** @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 ( - '
' + + '
' + '

' + escapeHtml(title) + "

" + + '
' + + '' + + ICON_NO_DUTY + + "" + '

' + escapeHtml(noDuty) + "

" + + "
" + '" + @@ -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 ( '
' + '

' + + ' ' + escapeHtml(title) + "

" + '

' + @@ -97,6 +127,9 @@ export function renderCurrentDutyContent(duty, lang) { ": " + escapeHtml(shiftStr) + "

" + + '
' + + escapeHtml(remainingStr) + + "
" + contactHtml + '
"; }); + 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/"); diff --git a/webapp/js/i18n.js b/webapp/js/i18n.js index 90abbbb..7f245ed 100644 --- a/webapp/js/i18n.js +++ b/webapp/js/i18n.js @@ -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": "Назад к календарю" } };