feat: enhance CI workflow and update webapp styles
Some checks failed
CI / lint-and-test (push) Failing after 45s
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:
@@ -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
94
webapp/css/base.css
Normal 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
185
webapp/css/calendar.css
Normal 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
219
webapp/css/day-detail.css
Normal 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
330
webapp/css/duty-list.css
Normal 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
70
webapp/css/hints.css
Normal 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
45
webapp/css/markers.css
Normal 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
183
webapp/css/states.css
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
88
webapp/js/contactHtml.js
Normal 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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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>";
|
||||
}
|
||||
100
webapp/js/contactHtml.test.js
Normal file
100
webapp/js/contactHtml.test.js
Normal 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(""");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain(">");
|
||||
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(" · ");
|
||||
});
|
||||
});
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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, """) + '" 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) {
|
||||
|
||||
@@ -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("&");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
152
webapp/js/theme.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
122
webapp/js/ui.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1124
webapp/style.css
1124
webapp/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user