feat: enhance UI components and error handling

- Updated HTML structure for navigation buttons in the calendar, adding SVG icons for improved visual clarity.
- Introduced a new muted text style in CSS for better presentation of empty duty list messages.
- Enhanced calendar CSS for navigation buttons and day indicators, improving layout and responsiveness.
- Improved error handling in the UI by adding retry functionality to the error display, allowing users to retry actions directly from the error message.
- Updated internationalization messages to include a retry option for error handling.
- Added unit tests to verify the new error handling behavior and UI updates.
This commit is contained in:
2026-03-02 20:21:33 +03:00
parent 37d4226beb
commit 54446d7b0f
12 changed files with 154 additions and 19 deletions

View File

@@ -89,6 +89,15 @@ body {
padding-bottom: env(safe-area-inset-bottom, 12px); padding-bottom: env(safe-area-inset-bottom, 12px);
} }
/* Muted text (e.g. empty duty list message). */
.muted {
color: var(--muted);
}
[data-theme="light"] .container { [data-theme="light"] .container {
border-radius: 12px; border-radius: 12px;
} }
[data-theme="dark"] .container {
border-radius: 12px;
}

View File

@@ -21,7 +21,21 @@
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast); display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity var(--transition-fast), transform var(--transition-fast),
background-color var(--transition-fast);
}
.nav svg {
display: block;
}
@media (hover: hover) {
.nav:hover {
background: color-mix(in srgb, var(--accent) 15%, var(--surface));
}
} }
.nav:focus { .nav:focus {
@@ -48,6 +62,12 @@
font-weight: 600; font-weight: 600;
} }
@media (max-width: 480px) {
.title {
font-size: 1.25rem;
}
}
.weekdays { .weekdays {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
@@ -92,13 +112,23 @@
font-size: 0.85rem; font-size: 0.85rem;
background: var(--surface); background: var(--surface);
min-width: 0; min-width: 0;
min-height: 0; min-height: 32px;
overflow: hidden; overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast); transition: background-color var(--transition-fast), transform var(--transition-fast);
} }
@media (hover: hover) {
.day:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.day.today:hover {
background: color-mix(in srgb, var(--bg) 15%, var(--today));
}
}
.day.other-month { .day.other-month {
opacity: 0.4; opacity: 0.4;
background: color-mix(in srgb, var(--muted) 8%, var(--surface));
} }
.day.today { .day.today {
@@ -142,6 +172,7 @@
.day-indicator { .day-indicator {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 2px;
width: 65%; width: 65%;
margin-top: 6px; margin-top: 6px;
} }

View File

@@ -62,10 +62,10 @@ body.day-detail-sheet-open {
.day-detail-panel--sheet::before { .day-detail-panel--sheet::before {
content: ""; content: "";
display: block; display: block;
width: 36px; width: 40px;
height: 4px; height: 4px;
margin: 0 auto 8px; margin: 0 auto 8px;
background: var(--muted); background: color-mix(in srgb, var(--muted) 80%, var(--text));
border-radius: 2px; border-radius: 2px;
} }
@@ -79,13 +79,19 @@ body.day-detail-sheet-open {
border: none; border: none;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-size: 1.5rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
border-radius: 8px; border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity var(--transition-fast), background-color var(--transition-fast); transition: opacity var(--transition-fast), background-color var(--transition-fast);
} }
.day-detail-close svg {
display: block;
}
.day-detail-close:focus { .day-detail-close:focus {
outline: none; outline: none;
} }

View File

@@ -41,7 +41,12 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
background: var(--muted); background: linear-gradient(
to bottom,
var(--muted) 0%,
var(--muted) 85%,
color-mix(in srgb, var(--muted) 40%, transparent) 100%
);
pointer-events: none; pointer-events: none;
} }
@@ -185,7 +190,7 @@
} }
.duty-flip-inner { .duty-flip-inner {
transition: transform 0.4s; transition: transform 0.3s;
transform-style: preserve-3d; transform-style: preserve-3d;
position: relative; position: relative;
min-height: 0; min-height: 0;
@@ -253,6 +258,7 @@
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface);
border-left: 3px solid var(--duty); border-left: 3px solid var(--duty);
box-shadow: 0 1px 3px color-mix(in srgb, var(--text) 8%, transparent);
} }
.duty-item--unavailable { .duty-item--unavailable {

View File

@@ -3,7 +3,7 @@
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
width: max-content; width: max-content;
max-width: min(98vw, 900px); max-width: min(98vw, 380px);
min-width: 0; min-width: 0;
padding: 8px 12px; padding: 8px 12px;
background: var(--surface); background: var(--surface);

View File

@@ -33,7 +33,7 @@
} }
} }
.loading, .error { .loading {
text-align: center; text-align: center;
padding: 12px; padding: 12px;
color: var(--muted); color: var(--muted);
@@ -45,7 +45,16 @@
} }
.error { .error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 16px;
margin: 12px 0;
background: var(--surface);
border-radius: 12px;
color: var(--error); color: var(--error);
text-align: center;
} }
.error[hidden], .loading.hidden, .error[hidden], .loading.hidden,
@@ -53,6 +62,36 @@
display: none !important; display: none !important;
} }
.error-icon {
flex-shrink: 0;
color: var(--error);
}
.error-text {
margin: 0;
}
.error-retry {
margin-top: 4px;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
}
.error-retry:hover {
opacity: 0.9;
}
.error-retry:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Current duty view (Mini App deep link startapp=duty) */ /* Current duty view (Mini App deep link startapp=duty) */
[data-view="currentDuty"] .calendar-sticky, [data-view="currentDuty"] .calendar-sticky,
[data-view="currentDuty"] .duty-list { [data-view="currentDuty"] .duty-list {
@@ -167,11 +206,19 @@
color: var(--muted); color: var(--muted);
margin-bottom: 16px; margin-bottom: 16px;
line-height: 0; line-height: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: color-mix(in srgb, var(--muted) 25%, transparent);
padding: 12px;
box-sizing: border-box;
} }
.current-duty-no-duty-icon svg { .current-duty-no-duty-icon svg {
display: block; display: block;
opacity: 0.7; width: 100%;
height: 100%;
opacity: 0.85;
} }
.current-duty-no-duty { .current-duty-no-duty {
@@ -282,8 +329,12 @@
.access-denied { .access-denied {
text-align: center; text-align: center;
padding: 24px 12px; padding: 24px 16px;
margin: 12px 0;
background: var(--surface);
border-radius: 12px;
color: var(--muted); color: var(--muted);
box-shadow: 0 1px 3px color-mix(in srgb, var(--text) 8%, transparent);
} }
.access-denied p { .access-denied p {

View File

@@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="favicon.png" type="image/png">
<title></title> <title></title>
<link rel="stylesheet" href="css/base.css"> <link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/calendar.css"> <link rel="stylesheet" href="css/calendar.css">
@@ -17,9 +16,13 @@
<div class="container"> <div class="container">
<div class="calendar-sticky" id="calendarSticky"> <div class="calendar-sticky" id="calendarSticky">
<header class="header"> <header class="header">
<button type="button" class="nav" id="prevMonth" aria-label=""></button> <button type="button" class="nav nav--prev" id="prevMonth" aria-label="">
<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"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<h1 class="title" id="monthTitle"></h1> <h1 class="title" id="monthTitle"></h1>
<button type="button" class="nav" id="nextMonth" aria-label=""></button> <button type="button" class="nav nav--next" id="nextMonth" aria-label="">
<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"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</header> </header>
<div class="weekdays"> <div class="weekdays">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span> <span></span><span></span><span></span><span></span><span></span><span></span><span></span>

View File

@@ -339,10 +339,14 @@ function ensurePanelInDom() {
panelEl.hidden = true; panelEl.hidden = true;
const closeLabel = t(state.lang, "day_detail.close"); const closeLabel = t(state.lang, "day_detail.close");
const closeIcon =
'<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
panelEl.innerHTML = panelEl.innerHTML =
'<button type="button" class="day-detail-close" aria-label="' + '<button type="button" class="day-detail-close" aria-label="' +
escapeHtml(closeLabel) + escapeHtml(closeLabel) +
'">×</button><div class="day-detail-body"></div>'; '">' +
closeIcon +
'</button><div class="day-detail-body"></div>';
const closeBtn = panelEl.querySelector(".day-detail-close"); const closeBtn = panelEl.querySelector(".day-detail-close");
if (closeBtn) { if (closeBtn) {

View File

@@ -13,6 +13,7 @@ export const MESSAGES = {
error_load_failed: "Load failed", error_load_failed: "Load failed",
error_network: "Could not load data. Check your connection.", error_network: "Could not load data. Check your connection.",
error_generic: "Could not load data.", error_generic: "Could not load data.",
"error.retry": "Retry",
"nav.prev_month": "Previous month", "nav.prev_month": "Previous month",
"nav.next_month": "Next month", "nav.next_month": "Next month",
"weekdays.mon": "Mon", "weekdays.mon": "Mon",
@@ -68,6 +69,7 @@ export const MESSAGES = {
error_load_failed: "Ошибка загрузки", error_load_failed: "Ошибка загрузки",
error_network: "Не удалось загрузить данные. Проверьте интернет.", error_network: "Не удалось загрузить данные. Проверьте интернет.",
error_generic: "Не удалось загрузить данные.", error_generic: "Не удалось загрузить данные.",
"error.retry": "Повторить",
"nav.prev_month": "Предыдущий месяц", "nav.prev_month": "Предыдущий месяц",
"nav.next_month": "Следующий месяц", "nav.next_month": "Следующий месяц",
"weekdays.mon": "Пн", "weekdays.mon": "Пн",

View File

@@ -189,7 +189,7 @@ async function loadMonth() {
} }
return; return;
} }
showError(e.message || t(state.lang, "error_generic")); showError(e.message || t(state.lang, "error_generic"), loadMonth);
setNavEnabled(true); setNavEnabled(true);
return; return;
} }

View File

@@ -15,6 +15,7 @@ import {
getNextBtn getNextBtn
} from "./dom.js"; } from "./dom.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
/** /**
* Show access-denied view and hide calendar/list/loading/error. * Show access-denied view and hide calendar/list/loading/error.
@@ -63,16 +64,37 @@ export function hideAccessDenied() {
if (dutyListEl) dutyListEl.hidden = false; if (dutyListEl) dutyListEl.hidden = false;
} }
/** Warning icon SVG for error state (24×24). */
const ERROR_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="error-icon" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
/** /**
* Show error message and hide loading. * Show error message and hide loading.
* @param {string} msg - Error text * @param {string} msg - Error text
* @param {(() => void)|null} [onRetry] - Optional callback for Retry button
*/ */
export function showError(msg) { export function showError(msg, onRetry) {
const errorEl = getErrorEl(); const errorEl = getErrorEl();
const loadingEl = getLoadingEl(); const loadingEl = getLoadingEl();
if (errorEl) { if (errorEl) {
errorEl.textContent = msg; const retryLabel = t(state.lang, "error.retry");
const safeMsg = typeof msg === "string" ? msg : t(state.lang, "error_generic");
let html =
ERROR_ICON_SVG +
'<p class="error-text">' +
escapeHtml(safeMsg) +
"</p>";
if (typeof onRetry === "function") {
html += '<button type="button" class="error-retry">' + escapeHtml(retryLabel) + "</button>";
}
errorEl.innerHTML = html;
errorEl.hidden = false; errorEl.hidden = false;
const retryBtn = errorEl.querySelector(".error-retry");
if (retryBtn && typeof onRetry === "function") {
retryBtn.addEventListener("click", () => {
onRetry();
});
}
} }
if (loadingEl) loadingEl.classList.add("hidden"); if (loadingEl) loadingEl.classList.add("hidden");
} }

View File

@@ -93,7 +93,8 @@ describe("ui", () => {
it("sets error text and shows error element", () => { it("sets error text and shows error element", () => {
showError("Network error"); showError("Network error");
const errorEl = document.getElementById("error"); const errorEl = document.getElementById("error");
expect(errorEl?.textContent).toBe("Network error"); const textEl = errorEl?.querySelector(".error-text");
expect(textEl?.textContent).toBe("Network error");
expect(errorEl?.hidden).toBe(false); expect(errorEl?.hidden).toBe(false);
}); });