feat: enhance CI workflow and update webapp styles
Some checks failed
CI / lint-and-test (push) Failing after 45s

- Added Node.js setup and webapp testing steps to the CI workflow for improved integration.
- Updated HTML to link multiple CSS files for better modularity and organization of styles.
- Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability.
- Implemented new styles for improved presentation of duty information and user interactions.
- Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
This commit is contained in:
2026-03-02 17:20:33 +03:00
parent e3240d0981
commit 2fb553567f
29 changed files with 2212 additions and 1375 deletions

View File

@@ -39,3 +39,14 @@ jobs:
- name: Security check with Bandit
run: |
bandit -r duty_teller -ll
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: "20"
- name: Webapp tests
run: |
cd webapp
npm ci
npm run test

94
webapp/css/base.css Normal file
View File

@@ -0,0 +1,94 @@
/* === Variables & themes */
:root {
--bg: #1a1b26;
--surface: #24283b;
--text: #c0caf5;
--muted: #565f89;
--accent: #7aa2f7;
--duty: #9ece6a;
--today: #bb9af7;
--unavailable: #e0af68;
--vacation: #7dcfff;
--error: #f7768e;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
[data-theme="light"] {
--bg: var(--tg-theme-bg-color, #f0f1f3);
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
--text: var(--tg-theme-text-color, #343b58);
--muted: var(--tg-theme-hint-color, #6b7089);
--accent: var(--tg-theme-link-color, #2e7de0);
--duty: #587d0a;
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
--error: #c43b3b;
}
/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
[data-theme="dark"] {
--bg: var(--tg-theme-bg-color, #17212b);
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
--text: var(--tg-theme-text-color, #f5f5f5);
--muted: var(--tg-theme-hint-color, #708499);
--accent: var(--tg-theme-link-color, #6ab3f3);
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
--duty: #5c9b4a;
--unavailable: #b8860b;
--vacation: #5a9bb8;
--error: #e06c75;
}
/* === Layout & base */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.container {
max-width: 420px;
margin: 0 auto;
padding: 12px;
padding-top: 0px;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
[data-theme="light"] .container {
border-radius: 12px;
}

185
webapp/css/calendar.css Normal file
View File

@@ -0,0 +1,185 @@
/* === Calendar: header, nav, weekdays, grid, day cells */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header[hidden],
.weekdays[hidden] {
display: none !important;
}
.nav {
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--surface);
color: var(--accent);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav:focus {
outline: none;
}
.nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.nav:active {
transform: scale(0.95);
}
.nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 6px;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
}
.calendar-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding-bottom: 12px;
margin-bottom: 4px;
touch-action: pan-y;
transition: box-shadow var(--transition-fast) ease-out;
}
.calendar-sticky.is-scrolled {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 16px;
}
.day {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px;
border-radius: 8px;
font-size: 0.85rem;
background: var(--surface);
min-width: 0;
min-height: 0;
overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.day.other-month {
opacity: 0.4;
}
.day.today {
background: var(--today);
color: var(--bg);
}
.day.has-duty .num {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
.day.today.holiday {
background: var(--today);
color: var(--bg);
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
}
.day {
cursor: pointer;
}
.day:focus {
outline: none;
}
.day:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day:active {
transform: scale(0.98);
}
.day-indicator {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
margin-top: 6px;
}
.day-indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* On "today" cell: dots darkened for contrast on --today background */
.day.today .day-indicator-dot.duty {
background: color-mix(in srgb, var(--duty) 65%, var(--bg));
}
.day.today .day-indicator-dot.unavailable {
background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
}
.day.today .day-indicator-dot.vacation {
background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
}
.day.today .day-indicator-dot.events {
background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}

219
webapp/css/day-detail.css Normal file
View File

@@ -0,0 +1,219 @@
/* === Day detail panel (popover / bottom sheet) */
/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
body.day-detail-sheet-open {
position: fixed;
left: 0;
right: 0;
overflow: hidden;
}
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal) ease-out;
}
.day-detail-overlay.day-detail-overlay--visible {
opacity: 1;
pointer-events: auto;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
/* Комфортный отступ снизу: safe area + дополнительное поле */
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform var(--transition-normal) var(--ease-out);
}
.day-detail-panel--sheet.day-detail-panel--open {
transform: translateY(0);
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.day-detail-close:focus {
outline: none;
}
.day-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
/* Contact info: phone (tel:) and Telegram username links in day detail */
.day-detail-contact-row {
margin-top: 4px;
font-size: 0.85rem;
color: var(--muted);
}
.day-detail-contact {
display: inline-block;
margin-right: 0.75em;
}
.day-detail-contact:last-child {
margin-right: 0;
}
.day-detail-contact-link,
.day-detail-contact-phone,
.day-detail-contact-username {
color: var(--accent);
text-decoration: none;
}
.day-detail-contact-link:hover,
.day-detail-contact-phone:hover,
.day-detail-contact-username:hover {
text-decoration: underline;
}
.day-detail-contact-link:focus,
.day-detail-contact-phone:focus,
.day-detail-contact-username:focus {
outline: none;
}
.day-detail-contact-link:focus-visible,
.day-detail-contact-phone:focus-visible,
.day-detail-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.day-markers {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 2px;
align-items: center;
margin-top: 2px;
min-width: 0;
}

330
webapp/css/duty-list.css Normal file
View File

@@ -0,0 +1,330 @@
/* === Duty list & timeline */
.duty-list {
font-size: 0.9rem;
}
.duty-list h2 {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 8px 0;
}
.duty-list-day {
margin-bottom: 16px;
}
.duty-list-day--today .duty-list-day-title {
color: var(--today);
font-weight: 700;
}
.duty-list-day--today .duty-list-day-title::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
background: var(--today);
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
/* Timeline: dates | track (line + dot) | cards */
.duty-list.duty-timeline {
position: relative;
}
.duty-list.duty-timeline::before {
content: "";
position: absolute;
left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
top: 0;
bottom: 0;
width: 2px;
background: var(--muted);
pointer-events: none;
}
.duty-timeline-day {
margin-bottom: 0;
}
.duty-timeline-day--today {
scroll-margin-top: 200px;
}
.duty-timeline-row {
display: grid;
grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
gap: 0 4px;
align-items: start;
margin-bottom: 8px;
min-height: 1px;
}
.duty-timeline-date {
position: relative;
font-size: 0.8rem;
color: var(--muted);
padding-top: 10px;
padding-bottom: 10px;
flex-shrink: 0;
overflow: visible;
}
.duty-timeline-date::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
color-mix(in srgb, var(--muted) 40%, transparent) 0%,
color-mix(in srgb, var(--muted) 40%, transparent) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
.duty-timeline-day--today .duty-timeline-date {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: 4px;
color: var(--today);
font-weight: 600;
}
.duty-timeline-day--today .duty-timeline-date::before,
.duty-timeline-day--today .duty-timeline-date::after {
display: none;
}
.duty-timeline-date-label,
.duty-timeline-date-day {
display: block;
line-height: 1.25;
}
.duty-timeline-date-day {
align-self: flex-start;
text-align: left;
padding-left: 0;
margin-left: 0;
}
.duty-timeline-date-dot {
display: block;
width: 100%;
height: 8px;
min-height: 8px;
position: relative;
flex-shrink: 0;
}
.duty-timeline-date-dot::before {
content: "";
position: absolute;
left: 0;
top: 50%;
margin-top: -1px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: color-mix(in srgb, var(--today) 45%, transparent);
}
.duty-timeline-date-dot::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
top: 50%;
margin-top: -3px;
width: 2px;
height: 6px;
background: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
color: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
color: var(--muted);
font-weight: 400;
font-size: 0.75rem;
}
.duty-timeline-track {
min-width: 0;
}
.duty-timeline-card-wrap {
min-width: 0;
}
/* Flip-card: front = duty info + button, back = contacts */
.duty-flip-card {
perspective: 600px;
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.duty-flip-inner {
transition: transform 0.4s;
transform-style: preserve-3d;
position: relative;
min-height: 0;
background: transparent;
}
.duty-flip-card[data-flipped="true"] .duty-flip-inner {
transform: rotateY(180deg);
}
.duty-flip-front {
position: relative;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.duty-flip-back {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
.duty-flip-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--surface);
color: var(--accent);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
.duty-flip-btn:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
}
.duty-flip-btn:focus {
outline: none;
}
.duty-flip-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-timeline-card.duty-item,
.duty-list .duty-item {
display: grid;
grid-template-columns: 1fr;
gap: 2px 0;
align-items: baseline;
padding: 8px 10px;
margin-bottom: 0;
border-radius: 8px;
background: var(--surface);
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: var(--unavailable);
}
.duty-item--vacation {
border-left-color: var(--vacation);
}
.duty-item .duty-item-type {
grid-column: 1;
grid-row: 1;
font-size: 0.75rem;
color: var(--muted);
}
.duty-item .name {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
font-weight: 600;
}
.duty-item .time {
grid-column: 1;
grid-row: 2;
align-self: start;
font-size: 0.8rem;
color: var(--muted);
}
.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
/* Contact info: phone and Telegram username links in duty timeline cards */
.duty-contact-row {
grid-column: 1;
grid-row: 4;
font-size: 0.8rem;
color: var(--muted);
margin-top: 2px;
}
.duty-contact-link,
.duty-contact-phone,
.duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.duty-contact-link:hover,
.duty-contact-phone:hover,
.duty-contact-username:hover {
text-decoration: underline;
}
.duty-contact-link:focus,
.duty-contact-phone:focus,
.duty-contact-username:focus {
outline: none;
}
.duty-contact-link:focus-visible,
.duty-contact-phone:focus-visible,
.duty-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-item--current {
border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface));
}

70
webapp/css/hints.css Normal file
View File

@@ -0,0 +1,70 @@
/* === Hints (tooltips) */
.calendar-event-hint {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(98vw, 900px);
min-width: 0;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
overflow: visible;
transform: translateY(-100%);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.calendar-event-hint:not(.calendar-event-hint--visible) {
opacity: 0;
}
.calendar-event-hint.calendar-event-hint--visible {
opacity: 1;
}
.calendar-event-hint.below {
transform: none;
}
.calendar-event-hint-title {
margin-bottom: 4px;
font-weight: 600;
}
.calendar-event-hint-rows {
display: table;
width: min-content;
table-layout: auto;
border-collapse: separate;
border-spacing: 0 2px;
}
.calendar-event-hint-row {
display: table-row;
white-space: nowrap;
}
.calendar-event-hint-row .calendar-event-hint-time {
display: table-cell;
white-space: nowrap;
width: 1%;
vertical-align: top;
text-align: right;
padding-right: 0.15em;
}
.calendar-event-hint-row .calendar-event-hint-sep {
display: table-cell;
width: 1em;
vertical-align: top;
padding-right: 0.1em;
}
.calendar-event-hint-row .calendar-event-hint-name {
display: table-cell;
white-space: nowrap !important;
}

45
webapp/css/markers.css Normal file
View File

@@ -0,0 +1,45 @@
/* === Markers (duty / unavailable / vacation) */
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: box-shadow var(--transition-fast) ease-out;
}
.duty-marker {
color: var(--duty);
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.unavailable-marker {
color: var(--unavailable);
background: color-mix(in srgb, var(--unavailable) 25%, transparent);
}
.vacation-marker {
color: var(--vacation);
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}

183
webapp/css/states.css Normal file
View File

@@ -0,0 +1,183 @@
/* === Loading / error / access denied / current duty view */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
color: var(--muted);
text-align: center;
}
.loading__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading__spinner {
animation: none;
border-top-color: var(--accent);
border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.loading, .error {
text-align: center;
padding: 12px;
color: var(--muted);
}
.error,
.access-denied {
transition: opacity 0.2s ease-out;
}
.error {
color: var(--error);
}
.error[hidden], .loading.hidden,
.current-duty-view.hidden {
display: none !important;
}
/* Current duty view (Mini App deep link startapp=duty) */
[data-view="currentDuty"] .calendar-sticky,
[data-view="currentDuty"] .duty-list {
display: none !important;
}
.current-duty-view {
padding: 24px 16px;
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.current-duty-card {
background: var(--surface);
border-radius: 12px;
padding: 24px;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.current-duty-title {
margin: 0 0 16px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.current-duty-name {
margin: 0 0 8px 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--duty);
}
.current-duty-shift {
margin: 0 0 12px 0;
font-size: 0.95rem;
color: var(--muted);
}
.current-duty-no-duty,
.current-duty-error {
margin: 0 0 16px 0;
color: var(--muted);
}
.current-duty-error {
color: var(--error);
}
.current-duty-contact-row {
margin: 12px 0 20px 0;
}
.current-duty-contact {
display: inline-block;
margin-right: 12px;
font-size: 0.95rem;
}
.current-duty-contact-link,
.current-duty-contact-phone,
.current-duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.current-duty-contact-link:hover,
.current-duty-contact-phone:hover,
.current-duty-contact-username:hover {
text-decoration: underline;
}
.current-duty-back-btn {
display: block;
width: 100%;
padding: 12px 16px;
margin-top: 8px;
font-size: 1rem;
font-weight: 500;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
}
.current-duty-back-btn:hover {
opacity: 0.9;
}
.current-duty-back-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.current-duty-loading {
text-align: center;
color: var(--muted);
}
.access-denied {
text-align: center;
padding: 24px 12px;
color: var(--muted);
}
.access-denied p {
margin: 0 0 8px 0;
}
.access-denied p:first-child {
color: var(--error);
font-weight: 600;
}
.access-denied .access-denied-detail {
margin-top: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.access-denied[hidden] {
display: none !important;
}

View File

@@ -5,7 +5,13 @@
<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>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/calendar.css">
<link rel="stylesheet" href="css/day-detail.css">
<link rel="stylesheet" href="css/hints.css">
<link rel="stylesheet" href="css/markers.css">
<link rel="stylesheet" href="css/duty-list.css">
<link rel="stylesheet" href="css/states.css">
</head>
<body>
<div class="container">

View File

@@ -67,7 +67,6 @@ export async function apiGet(path, params = {}, options = {}) {
* @returns {Promise<object[]>}
*/
export async function fetchDuties(from, to, signal) {
try {
const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
@@ -88,10 +87,6 @@ export async function fetchDuties(from, to, signal) {
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
} catch (e) {
if (e.name === "AbortError") throw e;
throw e;
}
}
/**

View File

@@ -15,7 +15,7 @@ beforeAll(() => {
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { buildFetchOptions, fetchDuties } from "./api.js";
import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js";
import { state } from "./dom.js";
describe("buildFetchOptions", () => {
@@ -102,3 +102,111 @@ describe("fetchDuties", () => {
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("apiGet", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "en";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("builds URL with path and query params and returns response", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true, status: 200 });
});
await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" });
expect(capturedUrl).toContain("/api/duties");
expect(capturedUrl).toContain("from=2025-02-01");
expect(capturedUrl).toContain("to=2025-02-28");
});
it("omits query string when params empty", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true });
});
await apiGet("/api/health", {});
expect(capturedUrl).toBe(window.location.origin + "/api/health");
});
it("passes X-Telegram-Init-Data and Accept-Language headers", async () => {
let capturedOpts = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedOpts = opts;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" });
expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data");
expect(capturedOpts?.headers["Accept-Language"]).toBe("en");
});
it("passes an abort signal to fetch when options.signal provided", async () => {
const controller = new AbortController();
let capturedSignal = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedSignal = opts.signal;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", {}, { signal: controller.signal });
expect(capturedSignal).toBeDefined();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
});
describe("fetchCalendarEvents", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON array on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
});
it("returns empty array on non-OK response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("returns empty array on 403 (does not throw)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError));
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});

View File

@@ -7,6 +7,7 @@ import {
getTgWebAppDataFromHash,
getInitData,
isLocalhost,
hasTelegramHashButNoInitData,
} from "./auth.js";
describe("getTgWebAppDataFromHash", () => {
@@ -98,3 +99,57 @@ describe("isLocalhost", () => {
expect(isLocalhost()).toBe(false);
});
});
describe("hasTelegramHashButNoInitData", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns false when hash is empty", () => {
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns true when hash has tgWebAppVersion but no tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(true);
});
it("returns false when hash has both tgWebAppVersion and tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6&tgWebAppData=some%3Ddata",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has tgWebAppData in unencoded form (with & and =)", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=value&tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has no Telegram params", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#other=param",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
});

View File

@@ -2,7 +2,7 @@
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { getCalendarEl, getMonthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
@@ -71,6 +71,8 @@ export function renderCalendar(
dutiesByDateMap,
calendarEventsByDateMap
) {
const calendarEl = getCalendarEl();
const monthTitleEl = getMonthTitleEl();
if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(new Date(year, month, 1));

View File

@@ -3,7 +3,7 @@
* calendarEventsByDate.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
@@ -13,7 +13,8 @@ beforeAll(() => {
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import { dutiesByDate, calendarEventsByDate } from "./calendar.js";
import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js";
import { state } from "./dom.js";
describe("dutiesByDate", () => {
it("groups duty by single local day", () => {
@@ -93,3 +94,46 @@ describe("calendarEventsByDate", () => {
expect(calendarEventsByDate(undefined)).toEqual({});
});
});
describe("renderCalendar", () => {
beforeEach(() => {
state.lang = "en";
});
it("renders 42 cells (6 weeks)", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = calendarEl?.querySelectorAll(".day") ?? [];
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = Array.from(calendarEl?.querySelectorAll(".day") ?? []);
const dates = cells.map((c) => c.getAttribute("data-date"));
expect(dates.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d ?? ""))).toBe(true);
});
it("adds today class to cell matching today", () => {
const today = new Date();
renderCalendar(today.getFullYear(), today.getMonth(), {}, {});
const calendarEl = document.getElementById("calendar");
const todayKey =
today.getFullYear() +
"-" +
String(today.getMonth() + 1).padStart(2, "0") +
"-" +
String(today.getDate()).padStart(2, "0");
const todayCell = calendarEl?.querySelector('.day.today[data-date="' + todayKey + '"]');
expect(todayCell).toBeTruthy();
});
it("sets month title from state.lang and given year/month", () => {
state.lang = "en";
renderCalendar(2025, 1, {}, {});
const titleEl = document.getElementById("monthTitle");
expect(titleEl?.textContent).toContain("2025");
expect(titleEl?.textContent).toContain("February");
});
});

88
webapp/js/contactHtml.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* Shared HTML builder for contact links (phone, Telegram) used by day detail,
* current duty, and duty list.
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
/**
* Build HTML for contact links (phone, Telegram username).
* Validates phone/username, builds tel: and t.me hrefs, wraps in spans/links.
*
* @param {'ru'|'en'} lang - UI language for labels (when showLabels is true)
* @param {string|null|undefined} phone - Phone number
* @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 {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ")
* @returns {string} HTML string or "" if no valid contact
*/
export function buildContactLinksHtml(lang, phone, username, options) {
const { classPrefix, showLabels = true, separator = " " } = options || {};
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
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");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} 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) +
'" 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>";
}

View File

@@ -0,0 +1,100 @@
/**
* Unit tests for buildContactLinksHtml (contact links HTML builder).
*/
import { describe, it, expect } from "vitest";
import { buildContactLinksHtml } from "./contactHtml.js";
describe("buildContactLinksHtml", () => {
const baseOptions = { classPrefix: "test-contact", showLabels: true, separator: " " };
it("returns empty string when phone and username are missing", () => {
expect(buildContactLinksHtml("en", null, null, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", undefined, undefined, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", "", "", baseOptions)).toBe("");
expect(buildContactLinksHtml("en", " ", " ", baseOptions)).toBe("");
});
it("renders phone only with label and tel: link", () => {
const html = buildContactLinksHtml("en", "+79991234567", null, baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("Phone");
expect(html).not.toContain("t.me");
});
it("renders username only with label and t.me link", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("@alice_dev");
expect(html).toContain("Telegram");
expect(html).not.toContain("tel:");
});
it("renders both phone and username with labels", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("tel:");
expect(html).toContain("+79001112233");
expect(html).toContain("t.me");
expect(html).toContain("@bob");
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
});
it("strips leading @ from username and displays with @", () => {
const html = buildContactLinksHtml("en", null, "@alice", baseOptions);
expect(html).toContain("https://t.me/alice");
expect(html).toContain("@alice");
expect(html).not.toContain("@@");
});
it("handles multiple leading @ in username", () => {
const html = buildContactLinksHtml("en", null, "@@@user", baseOptions);
expect(html).toContain("https://t.me/user");
expect(html).toContain("@user");
});
it("escapes special characters in phone in href and text", () => {
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).not.toContain("<2>");
expect(html).not.toContain('"1"');
});
it("uses custom separator when showLabels is false", () => {
const html = buildContactLinksHtml("en", "+7999", "u1", {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
expect(html).toContain(" · ");
expect(html).not.toContain("Phone");
expect(html).not.toContain("Telegram");
expect(html).toContain("duty-contact-row");
expect(html).toContain("duty-contact-link");
});
it("uses Russian labels when lang is ru", () => {
const html = buildContactLinksHtml("ru", "+7999", null, baseOptions);
expect(html).toContain("Телефон");
const htmlTg = buildContactLinksHtml("ru", null, "u", baseOptions);
expect(htmlTg).toContain("Telegram");
});
it("uses default showLabels true and separator space when options omit them", () => {
const html = buildContactLinksHtml("en", "+7999", "u", {
classPrefix: "minimal",
});
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
expect(html).toContain("minimal-row");
expect(html).not.toContain(" · ");
});
});

View File

@@ -3,9 +3,10 @@
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
*/
import { currentDutyViewEl, state, loadingEl } from "./dom.js";
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,
@@ -13,6 +14,11 @@ import {
formatHHMM
} from "./dateUtils.js";
/** @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;
/**
* 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
@@ -30,54 +36,6 @@ export function findCurrentDuty(duties) {
return null;
}
/**
* Build contact HTML (phone + Telegram) for current duty card, styled like day-detail.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function buildContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const label = t(lang, "contact.phone");
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
safeHref +
'" class="current-duty-contact-link current-duty-contact-phone">' +
escapeHtml(p) +
"</a></span>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
escapeHtml(href) +
'" class="current-duty-contact-link current-duty-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a></span>"
);
}
}
return parts.length
? '<div class="current-duty-contact-row">' + parts.join(" ") + "</div>"
: "";
}
/**
* Render the current duty view content (card with duty or no-duty message).
* @param {object|null} duty - Active duty or null
@@ -120,7 +78,11 @@ export function renderCurrentDutyContent(duty, lang) {
" " +
endTime;
const shiftLabel = t(lang, "current_duty.shift");
const contactHtml = buildContactHtml(lang, duty);
const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, {
classPrefix: "current-duty-contact",
showLabels: true,
separator: " "
});
return (
'<div class="current-duty-card">' +
@@ -149,16 +111,18 @@ export function renderCurrentDutyContent(duty, lang) {
* @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;
currentDutyViewEl._onBack = onBack;
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;
@@ -170,9 +134,9 @@ export async function showCurrentDutyView(onBack) {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
window.Telegram.WebApp.BackButton.show();
const handler = () => {
if (currentDutyViewEl._onBack) currentDutyViewEl._onBack();
if (onBackCallback) onBackCallback();
};
currentDutyViewEl._backButtonHandler = handler;
backButtonHandler = handler;
window.Telegram.WebApp.BackButton.onClick(handler);
}
@@ -205,9 +169,7 @@ export async function showCurrentDutyView(onBack) {
function handleCurrentDutyClick(e) {
const btn = e.target && e.target.closest("[data-action='back']");
if (!btn) return;
if (currentDutyViewEl && currentDutyViewEl._onBack) {
currentDutyViewEl._onBack();
}
if (onBackCallback) onBackCallback();
}
/**
@@ -215,24 +177,24 @@ function handleCurrentDutyClick(e) {
* 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");
const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler;
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
if (backHandler) {
window.Telegram.WebApp.BackButton.offClick(backHandler);
if (backButtonHandler) {
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
}
window.Telegram.WebApp.BackButton.hide();
}
if (currentDutyViewEl) {
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
currentDutyViewEl._onBack = null;
currentDutyViewEl._backButtonHandler = null;
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;

View File

@@ -10,6 +10,10 @@ import {
dutyOverlapsLocalRange,
getMonday,
formatHHMM,
firstDayOfMonth,
lastDayOfMonth,
formatDateKey,
dateKeyToDDMM,
} from "./dateUtils.js";
describe("localDateString", () => {
@@ -157,3 +161,70 @@ describe("formatHHMM", () => {
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("firstDayOfMonth", () => {
it("returns first day of month", () => {
const d = new Date(2025, 5, 15);
const result = firstDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(5);
expect(result.getDate()).toBe(1);
});
it("handles January", () => {
const d = new Date(2025, 0, 31);
const result = firstDayOfMonth(d);
expect(result.getDate()).toBe(1);
expect(result.getMonth()).toBe(0);
});
});
describe("lastDayOfMonth", () => {
it("returns last day of month", () => {
const d = new Date(2025, 0, 15);
const result = lastDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(31);
});
it("returns 28 for non-leap February", () => {
const d = new Date(2023, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(28);
expect(result.getMonth()).toBe(1);
});
it("returns 29 for leap February", () => {
const d = new Date(2024, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(29);
});
});
describe("formatDateKey", () => {
it("formats ISO date string as DD.MM (local time)", () => {
const result = formatDateKey("2025-02-25T00:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
const [day, month] = result.split(".");
expect(Number(day)).toBeGreaterThanOrEqual(1);
expect(Number(day)).toBeLessThanOrEqual(31);
expect(Number(month)).toBeGreaterThanOrEqual(1);
expect(Number(month)).toBeLessThanOrEqual(12);
});
it("returns DD.MM format with zero-padding", () => {
const result = formatDateKey("2025-01-05T12:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
});
});
describe("dateKeyToDDMM", () => {
it("converts YYYY-MM-DD to DD.MM", () => {
expect(dateKeyToDDMM("2025-02-25")).toBe("25.02");
});
it("handles single-digit day and month", () => {
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
});
});

View File

@@ -2,9 +2,10 @@
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
import { getDutyMarkerRows } from "./hints.js";
@@ -35,43 +36,6 @@ function parseDataAttr(raw) {
}
}
/**
* Build HTML for contact info (phone link, Telegram username link) for a duty entry.
* @param {'ru'|'en'} lang
* @param {string|null|undefined} phone
* @param {string|null|undefined} username - Telegram username with or without leading @
* @returns {string}
*/
function buildContactHtml(lang, phone, username) {
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const label = t(lang, "contact.phone");
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + safeHref + '" class="day-detail-contact-link day-detail-contact-phone">' +
escapeHtml(p) + "</a></span>"
);
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + escapeHtml(href) + '" class="day-detail-contact-link day-detail-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) + "</a></span>"
);
}
}
return parts.length ? '<div class="day-detail-contact-row">' + parts.join(" ") + "</div>" : "";
}
/**
* Build HTML content for the day detail panel.
* @param {string} dateKey - YYYY-MM-DD
@@ -127,7 +91,11 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactHtml(lang, phone, username);
const contactHtml = buildContactLinksHtml(lang, phone, username, {
classPrefix: "day-detail-contact",
showLabels: true,
separator: " "
});
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
@@ -199,6 +167,7 @@ function positionPopover(panel, cellRect) {
const panelRect = panel.getBoundingClientRect();
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
let top = cellRect.bottom + 8;
/* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */
if (top + panelRect.height > vh - margin) {
top = cellRect.top - panelRect.height - 8;
panel.classList.add("day-detail-panel--below");
@@ -256,6 +225,7 @@ function showAsPopover(cellRect) {
const target = e.target instanceof Node ? e.target : null;
if (!target || !panelEl) return;
if (panelEl.contains(target)) return;
const calendarEl = getCalendarEl();
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
hideDayDetail();
};
@@ -390,6 +360,7 @@ function ensurePanelInDom() {
* Bind delegated click/keydown on calendar for .day cells.
*/
export function initDayDetail() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);

View File

@@ -1,39 +1,62 @@
/**
* DOM references and shared application state.
* Element refs are resolved lazily via getters so modules can be imported before DOM is ready.
*/
/** @type {HTMLDivElement|null} */
export const calendarEl = document.getElementById("calendar");
/** @returns {HTMLDivElement|null} */
export function getCalendarEl() {
return document.getElementById("calendar");
}
/** @type {HTMLElement|null} */
export const monthTitleEl = document.getElementById("monthTitle");
/** @returns {HTMLElement|null} */
export function getMonthTitleEl() {
return document.getElementById("monthTitle");
}
/** @type {HTMLDivElement|null} */
export const dutyListEl = document.getElementById("dutyList");
/** @returns {HTMLDivElement|null} */
export function getDutyListEl() {
return document.getElementById("dutyList");
}
/** @type {HTMLElement|null} */
export const loadingEl = document.getElementById("loading");
/** @returns {HTMLElement|null} */
export function getLoadingEl() {
return document.getElementById("loading");
}
/** @type {HTMLElement|null} */
export const errorEl = document.getElementById("error");
/** @returns {HTMLElement|null} */
export function getErrorEl() {
return document.getElementById("error");
}
/** @type {HTMLElement|null} */
export const accessDeniedEl = document.getElementById("accessDenied");
/** @returns {HTMLElement|null} */
export function getAccessDeniedEl() {
return document.getElementById("accessDenied");
}
/** @type {HTMLElement|null} */
export const headerEl = document.querySelector(".header");
/** @returns {HTMLElement|null} */
export function getHeaderEl() {
return document.querySelector(".header");
}
/** @type {HTMLElement|null} */
export const weekdaysEl = document.querySelector(".weekdays");
/** @returns {HTMLElement|null} */
export function getWeekdaysEl() {
return document.querySelector(".weekdays");
}
/** @type {HTMLButtonElement|null} */
export const prevBtn = document.getElementById("prevMonth");
/** @returns {HTMLButtonElement|null} */
export function getPrevBtn() {
return document.getElementById("prevMonth");
}
/** @type {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth");
/** @returns {HTMLButtonElement|null} */
export function getNextBtn() {
return document.getElementById("nextMonth");
}
/** @type {HTMLDivElement|null} */
export const currentDutyViewEl = document.getElementById("currentDutyView");
/** @returns {HTMLDivElement|null} */
export function getCurrentDutyViewEl() {
return document.getElementById("currentDutyView");
}
/** Currently viewed month (mutable). */
export const state = {

View File

@@ -2,9 +2,10 @@
* Duty list (timeline) rendering.
*/
import { dutyListEl, state } from "./dom.js";
import { getDutyListEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import {
localDateString,
firstDayOfMonth,
@@ -14,37 +15,6 @@ import {
formatDateKey
} from "./dateUtils.js";
/**
* Build HTML for contact links (phone, Telegram) for a duty. Returns empty string if none.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function dutyCardContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<a href="' + safeHref + '" class="duty-contact-link duty-contact-phone">' +
escapeHtml(p) + "</a>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<a href="' + href.replace(/"/g, "&quot;") + '" class="duty-contact-link duty-contact-username" target="_blank" rel="noopener noreferrer">@' +
escapeHtml(u) + "</a>"
);
}
}
return parts.length
? '<div class="duty-contact-row">' + parts.join(" · ") + "</div>"
: "";
}
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<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"><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>';
@@ -79,7 +49,11 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = dutyCardContactHtml(lang, d);
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
@@ -174,12 +148,12 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
timeOrRange +
escapeHtml(timeOrRange) +
"</div></div>"
);
}
/** Whether the delegated flip-button click listener has been attached to dutyListEl. */
/** Whether the delegated flip-button click listener has been attached to duty list element. */
let flipListenerAttached = false;
/**
@@ -187,6 +161,7 @@ let flipListenerAttached = false;
* @param {object[]} duties - Duties (only duty type used for timeline)
*/
export function renderDutyList(duties) {
const dutyListEl = getDutyListEl();
if (!dutyListEl) return;
if (!flipListenerAttached) {
@@ -277,8 +252,9 @@ export function renderDutyList(duties) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
const listEl = getDutyListEl();
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
if (currentDutyCard) {
scrollToEl(currentDutyCard);
} else if (todayBlock) {

View File

@@ -1,9 +1,10 @@
/**
* Unit tests for dutyList (dutyTimelineCardHtml, contact rendering).
* Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering).
*/
import { describe, it, expect, beforeAll } from "vitest";
import { dutyTimelineCardHtml } from "./dutyList.js";
import { describe, it, expect, beforeAll, vi, afterEach } from "vitest";
import * as dateUtils from "./dateUtils.js";
import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js";
describe("dutyList", () => {
beforeAll(() => {
@@ -89,4 +90,75 @@ describe("dutyList", () => {
expect(html).not.toContain("duty-contact-row");
});
});
describe("dutyItemHtml", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("escapes timeOrRange so HTML special chars are not rendered raw", () => {
vi.spyOn(dateUtils, "formatHHMM").mockReturnValue("12:00 & 13:00");
vi.spyOn(dateUtils, "formatDateKey").mockReturnValue("01.02.2025");
const d = {
event_type: "duty",
full_name: "Test",
start_at: "2025-03-01T12:00:00",
end_at: "2025-03-01T13:00:00",
};
const html = dutyItemHtml(d, null, false);
expect(html).toContain("&amp;");
expect(html).not.toContain('<div class="time">12:00 & 13:00');
});
it("uses typeLabelOverride when provided", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, "On duty now", false);
expect(html).toContain("On duty now");
expect(html).toContain("Alice");
});
it("shows duty.until when showUntilEnd is true for duty", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, true);
expect(html).toMatch(/until|до/);
expect(html).toMatch(/\d{2}:\d{2}/);
});
it("renders vacation with date range", () => {
vi.spyOn(dateUtils, "formatDateKey")
.mockReturnValueOnce("01.03")
.mockReturnValueOnce("05.03");
const d = {
event_type: "vacation",
full_name: "Charlie",
start_at: "2025-03-01T00:00:00",
end_at: "2025-03-05T23:59:59",
};
const html = dutyItemHtml(d);
expect(html).toContain("01.03 05.03");
expect(html).toContain("duty-item--vacation");
});
it("applies extraClass to container", () => {
const d = {
event_type: "duty",
full_name: "Dana",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, false, "duty-item--current");
expect(html).toContain("duty-item--current");
expect(html).toContain("Dana");
});
});
});

View File

@@ -2,7 +2,7 @@
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
* Remove active class from all duty/unavailable/vacation markers.
*/
export function clearActiveDutyMarker() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl
.querySelectorAll(
@@ -261,6 +262,25 @@ export function clearActiveDutyMarker() {
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const HINT_FADE_MS = 150;
/**
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
* @param {HTMLElement} hintEl - The hint element to dismiss
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
*/
export function dismissHint(hintEl, opts = {}) {
hintEl.classList.remove("calendar-event-hint--visible");
const id = setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
if (opts.clearActive) clearActiveDutyMarker();
if (typeof opts.afterHide === "function") opts.afterHide();
}, HINT_FADE_MS);
return id;
}
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/**
@@ -304,6 +324,7 @@ function getOrCreateDutyMarkerHint() {
export function initHints() {
const calendarEventHint = getOrCreateCalendarEventHint();
const dutyMarkerHint = getOrCreateDutyMarkerHint();
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
@@ -317,11 +338,7 @@ export function initHints() {
positionHint(calendarEventHint, btn.getBoundingClientRect());
calendarEventHint.dataset.active = "1";
} else {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
dismissHint(calendarEventHint);
}
return;
}
@@ -330,11 +347,7 @@ export function initHints() {
if (marker) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
}, 150);
dismissHint(dutyMarkerHint);
marker.classList.remove("calendar-marker-active");
return;
}
@@ -377,31 +390,23 @@ export function initHints() {
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
if (toMarker) return;
if (dutyMarkerHint.dataset.active) return;
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
dutyMarkerHideTimeout = setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
afterHide: () => {
dutyMarkerHideTimeout = null;
}, 150);
},
});
});
if (!state.calendarHintBound) {
state.calendarHintBound = true;
document.addEventListener("click", () => {
if (calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
dismissHint(calendarEventHint);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
dismissHint(calendarEventHint);
}
});
}
@@ -410,22 +415,12 @@ export function initHints() {
state.dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
}

View File

@@ -1,10 +1,11 @@
/**
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
* Also tests dismissHint helper.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { getDutyMarkerRows } from "./hints.js";
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { getDutyMarkerRows, dismissHint } from "./hints.js";
const FROM = "from";
const TO = "until";
@@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => {
expect(rows[2].timePrefix).toContain("15:00");
});
});
describe("dismissHint", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("removes visible class immediately and hides element after delay", () => {
const el = document.createElement("div");
el.classList.add("calendar-event-hint--visible");
el.hidden = false;
el.setAttribute("data-active", "1");
dismissHint(el);
expect(el.classList.contains("calendar-event-hint--visible")).toBe(false);
expect(el.hidden).toBe(false);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(true);
expect(el.hasAttribute("data-active")).toBe(false);
});
it("returns timeout id usable with clearTimeout", () => {
const el = document.createElement("div");
const id = dismissHint(el);
expect(id).toBeDefined();
clearTimeout(id);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(false);
});
it("calls afterHide callback after delay when provided", () => {
const el = document.createElement("div");
let called = false;
dismissHint(el, {
afterHide: () => {
called = true;
},
});
expect(called).toBe(false);
vi.advanceTimersByTime(150);
expect(called).toBe(true);
});
});

View File

@@ -8,12 +8,12 @@ import { getInitData, isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import {
state,
accessDeniedEl,
prevBtn,
nextBtn,
loadingEl,
errorEl,
weekdaysEl
getAccessDeniedEl,
getPrevBtn,
getNextBtn,
getLoadingEl,
getErrorEl,
getWeekdaysEl
} from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -39,15 +39,19 @@ initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
const loadingEl = getLoadingEl();
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
@@ -99,12 +103,14 @@ function requireTelegramOrLocalhost(onAllowed) {
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
@@ -121,7 +127,9 @@ async function loadMonth() {
hideAccessDenied();
setNavEnabled(false);
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.remove("hidden");
const errorEl = getErrorEl();
if (errorEl) errorEl.hidden = true;
const current = state.current;
const first = firstDayOfMonth(current);
@@ -185,21 +193,26 @@ async function loadMonth() {
setNavEnabled(true);
return;
}
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
setNavEnabled(true);
}
if (prevBtn) {
prevBtn.addEventListener("click", () => {
const prevBtnEl = getPrevBtn();
if (prevBtnEl) {
prevBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1);
loadMonth();
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
const nextBtnEl = getNextBtn();
if (nextBtnEl) {
nextBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() + 1);
loadMonth();
@@ -227,12 +240,15 @@ if (nextBtn) {
(e) => {
if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (deltaX > SWIPE_THRESHOLD) {
if (prevBtn && prevBtn.disabled) return;
state.current.setMonth(state.current.getMonth() - 1);

152
webapp/js/theme.test.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Unit tests for theme: getTheme, applyThemeParamsToCss, applyTheme, initTheme.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("theme", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("getTheme", () => {
it("returns Telegram.WebApp.colorScheme when set", async () => {
globalThis.window.Telegram = { WebApp: { colorScheme: "light" } };
vi.spyOn(document.documentElement.style, "getPropertyValue").mockReturnValue("");
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to --tg-color-scheme CSS when TWA has no colorScheme", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue("dark"),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("falls back to matchMedia prefers-color-scheme dark", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("returns light when matchMedia prefers light", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to matchMedia when getComputedStyle throws", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(() => {
throw new Error("getComputedStyle not available");
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
});
describe("applyThemeParamsToCss", () => {
it("does nothing when Telegram.WebApp or themeParams missing", async () => {
globalThis.window.Telegram = undefined;
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).not.toHaveBeenCalled();
});
it("sets --tg-theme-* CSS variables from themeParams", async () => {
globalThis.window.Telegram = {
WebApp: {
themeParams: {
bg_color: "#ffffff",
text_color: "#000000",
hint_color: "#888888",
},
},
};
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).toHaveBeenCalledWith("--tg-theme-bg-color", "#ffffff");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-text-color", "#000000");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-hint-color", "#888888");
});
});
describe("applyTheme", () => {
beforeEach(() => {
document.documentElement.dataset.theme = "";
});
it("sets data-theme on documentElement from getTheme", async () => {
const theme = await import("./theme.js");
vi.spyOn(theme, "getTheme").mockReturnValue("light");
theme.applyTheme();
expect(document.documentElement.dataset.theme).toBe("light");
});
it("calls setBackgroundColor and setHeaderColor when TWA present", async () => {
const setBackgroundColor = vi.fn();
const setHeaderColor = vi.fn();
globalThis.window.Telegram = {
WebApp: {
setBackgroundColor: setBackgroundColor,
setHeaderColor: setHeaderColor,
themeParams: null,
},
};
const { applyTheme } = await import("./theme.js");
applyTheme();
expect(setBackgroundColor).toHaveBeenCalledWith("bg_color");
expect(setHeaderColor).toHaveBeenCalledWith("bg_color");
});
});
describe("initTheme", () => {
it("runs without throwing when TWA present", async () => {
globalThis.window.Telegram = { WebApp: {} };
const { initTheme } = await import("./theme.js");
expect(() => initTheme()).not.toThrow();
});
it("adds matchMedia change listener when no TWA", async () => {
globalThis.window.Telegram = undefined;
const addEventListener = vi.fn();
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener,
removeEventListener: vi.fn(),
});
const { initTheme } = await import("./theme.js");
initTheme();
expect(addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});
});

View File

@@ -4,15 +4,15 @@
import {
state,
calendarEl,
dutyListEl,
loadingEl,
errorEl,
accessDeniedEl,
headerEl,
weekdaysEl,
prevBtn,
nextBtn
getCalendarEl,
getDutyListEl,
getLoadingEl,
getErrorEl,
getAccessDeniedEl,
getHeaderEl,
getWeekdaysEl,
getPrevBtn,
getNextBtn
} from "./dom.js";
import { t } from "./i18n.js";
@@ -21,6 +21,13 @@ import { t } from "./i18n.js";
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
*/
export function showAccessDenied(serverDetail) {
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
const loadingEl = getLoadingEl();
const errorEl = getErrorEl();
const accessDeniedEl = getAccessDeniedEl();
if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true;
if (calendarEl) calendarEl.hidden = true;
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
* Hide access-denied and show calendar/list/header/weekdays.
*/
export function hideAccessDenied() {
const accessDeniedEl = getAccessDeniedEl();
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
if (accessDeniedEl) accessDeniedEl.hidden = true;
if (headerEl) headerEl.hidden = false;
if (weekdaysEl) weekdaysEl.hidden = false;
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
* @param {string} msg - Error text
*/
export function showError(msg) {
const errorEl = getErrorEl();
const loadingEl = getLoadingEl();
if (errorEl) {
errorEl.textContent = msg;
errorEl.hidden = false;
@@ -68,6 +82,8 @@ export function showError(msg) {
* @param {boolean} enabled
*/
export function setNavEnabled(enabled) {
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
}

122
webapp/js/ui.test.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled.
*/
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import {
showAccessDenied,
hideAccessDenied,
showError,
setNavEnabled,
} from "./ui.js";
import { state } from "./dom.js";
describe("ui", () => {
beforeEach(() => {
state.lang = "ru";
const calendar = document.getElementById("calendar");
const dutyList = document.getElementById("dutyList");
const loading = document.getElementById("loading");
const error = document.getElementById("error");
const accessDenied = document.getElementById("accessDenied");
const header = document.querySelector(".header");
const weekdays = document.querySelector(".weekdays");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
if (header) header.hidden = false;
if (weekdays) weekdays.hidden = false;
if (calendar) calendar.hidden = false;
if (dutyList) dutyList.hidden = false;
if (loading) loading.classList.remove("hidden");
if (error) error.hidden = true;
if (accessDenied) accessDenied.hidden = true;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
});
describe("showAccessDenied", () => {
it("hides header, weekdays, calendar, dutyList, loading, error and shows accessDenied", () => {
showAccessDenied();
expect(document.querySelector(".header")?.hidden).toBe(true);
expect(document.querySelector(".weekdays")?.hidden).toBe(true);
expect(document.getElementById("calendar")?.hidden).toBe(true);
expect(document.getElementById("dutyList")?.hidden).toBe(true);
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
expect(document.getElementById("error")?.hidden).toBe(true);
expect(document.getElementById("accessDenied")?.hidden).toBe(false);
});
it("sets accessDenied innerHTML with translated message", () => {
showAccessDenied();
const el = document.getElementById("accessDenied");
expect(el?.innerHTML).toContain("Доступ запрещён");
});
it("appends serverDetail in .access-denied-detail when provided", () => {
showAccessDenied("Custom 403 message");
const el = document.getElementById("accessDenied");
const detail = el?.querySelector(".access-denied-detail");
expect(detail?.textContent).toBe("Custom 403 message");
});
it("does not append detail element when serverDetail is empty string", () => {
showAccessDenied("");
const el = document.getElementById("accessDenied");
expect(el?.querySelector(".access-denied-detail")).toBeNull();
});
});
describe("hideAccessDenied", () => {
it("hides accessDenied and shows header, weekdays, calendar, dutyList", () => {
document.getElementById("accessDenied").hidden = false;
document.querySelector(".header").hidden = true;
document.getElementById("calendar").hidden = true;
hideAccessDenied();
expect(document.getElementById("accessDenied")?.hidden).toBe(true);
expect(document.querySelector(".header")?.hidden).toBe(false);
expect(document.querySelector(".weekdays")?.hidden).toBe(false);
expect(document.getElementById("calendar")?.hidden).toBe(false);
expect(document.getElementById("dutyList")?.hidden).toBe(false);
});
});
describe("showError", () => {
it("sets error text and shows error element", () => {
showError("Network error");
const errorEl = document.getElementById("error");
expect(errorEl?.textContent).toBe("Network error");
expect(errorEl?.hidden).toBe(false);
});
it("adds hidden class to loading element", () => {
document.getElementById("loading").classList.remove("hidden");
showError("Fail");
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
});
});
describe("setNavEnabled", () => {
it("disables prev and next buttons when enabled is false", () => {
setNavEnabled(false);
expect(document.getElementById("prevMonth")?.disabled).toBe(true);
expect(document.getElementById("nextMonth")?.disabled).toBe(true);
});
it("enables prev and next buttons when enabled is true", () => {
document.getElementById("prevMonth").disabled = true;
document.getElementById("nextMonth").disabled = true;
setNavEnabled(true);
expect(document.getElementById("prevMonth")?.disabled).toBe(false);
expect(document.getElementById("nextMonth")?.disabled).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff