- 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.
235 lines
8.0 KiB
JavaScript
235 lines
8.0 KiB
JavaScript
/**
|
|
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
|
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
|
|
*/
|
|
|
|
import { getCurrentDutyViewEl, state, getLoadingEl } from "./dom.js";
|
|
import { t } from "./i18n.js";
|
|
import { escapeHtml } from "./utils.js";
|
|
import { buildContactLinksHtml } from "./contactHtml.js";
|
|
import { fetchDuties } from "./api.js";
|
|
import {
|
|
localDateString,
|
|
dateKeyToDDMM,
|
|
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
|
|
* @returns {object|null}
|
|
*/
|
|
export function findCurrentDuty(duties) {
|
|
const now = Date.now();
|
|
const dutyType = (duties || []).filter((d) => d.event_type === "duty");
|
|
const candidates = dutyType.length ? dutyType : duties || [];
|
|
for (const d of candidates) {
|
|
const start = new Date(d.start_at).getTime();
|
|
const end = new Date(d.end_at).getTime();
|
|
if (start <= now && now < end) return d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Render the current duty view content (card with duty or no-duty message).
|
|
* @param {object|null} duty - Active duty or null
|
|
* @param {string} lang
|
|
* @returns {string}
|
|
*/
|
|
export function renderCurrentDutyContent(duty, lang) {
|
|
const backLabel = t(lang, "current_duty.back");
|
|
const title = t(lang, "current_duty.title");
|
|
|
|
if (!duty) {
|
|
const noDuty = t(lang, "current_duty.no_duty");
|
|
return (
|
|
'<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>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
const startLocal = localDateString(new Date(duty.start_at));
|
|
const endLocal = localDateString(new Date(duty.end_at));
|
|
const startDDMM = dateKeyToDDMM(startLocal);
|
|
const endDDMM = dateKeyToDDMM(endLocal);
|
|
const startTime = formatHHMM(duty.start_at);
|
|
const endTime = formatHHMM(duty.end_at);
|
|
const shiftStr =
|
|
startDDMM +
|
|
" " +
|
|
startTime +
|
|
" — " +
|
|
endDDMM +
|
|
" " +
|
|
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: " ",
|
|
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">' +
|
|
escapeHtml(duty.full_name) +
|
|
"</p>" +
|
|
'<div class="current-duty-shift">' +
|
|
escapeHtml(shiftLabel) +
|
|
": " +
|
|
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) +
|
|
"</button>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Show the current duty view: fetch today's duties, render card or no-duty, show back button.
|
|
* Hides calendar/duty list and shows #currentDutyView. Optionally shows Telegram BackButton.
|
|
* @param {() => void} onBack - Callback when user taps "Back to calendar"
|
|
*/
|
|
export async function showCurrentDutyView(onBack) {
|
|
const currentDutyViewEl = getCurrentDutyViewEl();
|
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
|
const calendarSticky = document.getElementById("calendarSticky");
|
|
const dutyList = document.getElementById("dutyList");
|
|
if (!currentDutyViewEl) return;
|
|
|
|
onBackCallback = onBack;
|
|
currentDutyViewEl.classList.remove("hidden");
|
|
if (container) container.setAttribute("data-view", "currentDuty");
|
|
if (calendarSticky) calendarSticky.hidden = true;
|
|
if (dutyList) dutyList.hidden = true;
|
|
const loadingEl = getLoadingEl();
|
|
if (loadingEl) loadingEl.classList.add("hidden");
|
|
|
|
const lang = state.lang;
|
|
currentDutyViewEl.innerHTML =
|
|
'<div class="current-duty-loading">' +
|
|
escapeHtml(t(lang, "loading")) +
|
|
"</div>";
|
|
|
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
|
window.Telegram.WebApp.BackButton.show();
|
|
const handler = () => {
|
|
if (onBackCallback) onBackCallback();
|
|
};
|
|
backButtonHandler = handler;
|
|
window.Telegram.WebApp.BackButton.onClick(handler);
|
|
}
|
|
|
|
const today = new Date();
|
|
const from = localDateString(today);
|
|
const to = from;
|
|
try {
|
|
const duties = await fetchDuties(from, to);
|
|
const duty = findCurrentDuty(duties);
|
|
currentDutyViewEl.innerHTML = renderCurrentDutyContent(duty, lang);
|
|
} catch (e) {
|
|
currentDutyViewEl.innerHTML =
|
|
'<div class="current-duty-card">' +
|
|
'<p class="current-duty-error">' +
|
|
escapeHtml(e.message || t(lang, "error_generic")) +
|
|
"</p>" +
|
|
'<button type="button" class="current-duty-back-btn" data-action="back">' +
|
|
escapeHtml(t(lang, "current_duty.back")) +
|
|
"</button>" +
|
|
"</div>";
|
|
}
|
|
|
|
currentDutyViewEl.addEventListener("click", handleCurrentDutyClick);
|
|
}
|
|
|
|
/**
|
|
* Delegate click for back button.
|
|
* @param {MouseEvent} e
|
|
*/
|
|
function handleCurrentDutyClick(e) {
|
|
const btn = e.target && e.target.closest("[data-action='back']");
|
|
if (!btn) return;
|
|
if (onBackCallback) onBackCallback();
|
|
}
|
|
|
|
/**
|
|
* Hide the current duty view and show calendar/duty list again.
|
|
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
|
|
*/
|
|
export function hideCurrentDutyView() {
|
|
const currentDutyViewEl = getCurrentDutyViewEl();
|
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
|
const calendarSticky = document.getElementById("calendarSticky");
|
|
const dutyList = document.getElementById("dutyList");
|
|
|
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
|
if (backButtonHandler) {
|
|
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
|
|
}
|
|
window.Telegram.WebApp.BackButton.hide();
|
|
}
|
|
if (currentDutyViewEl) {
|
|
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
|
|
currentDutyViewEl.classList.add("hidden");
|
|
currentDutyViewEl.innerHTML = "";
|
|
}
|
|
onBackCallback = null;
|
|
backButtonHandler = null;
|
|
if (container) container.removeAttribute("data-view");
|
|
if (calendarSticky) calendarSticky.hidden = false;
|
|
if (dutyList) dutyList.hidden = false;
|
|
}
|