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
|
- name: Security check with Bandit
|
||||||
run: |
|
run: |
|
||||||
bandit -r duty_teller -ll
|
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">
|
<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">
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
<title></title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export async function apiGet(path, params = {}, options = {}) {
|
|||||||
* @returns {Promise<object[]>}
|
* @returns {Promise<object[]>}
|
||||||
*/
|
*/
|
||||||
export async function fetchDuties(from, to, signal) {
|
export async function fetchDuties(from, to, signal) {
|
||||||
try {
|
|
||||||
const res = await apiGet("/api/duties", { from, to }, { signal });
|
const res = await apiGet("/api/duties", { from, to }, { signal });
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
let detail = t(state.lang, "access_denied");
|
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"));
|
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
|
||||||
return res.json();
|
return res.json();
|
||||||
} catch (e) {
|
|
||||||
if (e.name === "AbortError") throw e;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ beforeAll(() => {
|
|||||||
const mockGetInitData = vi.fn();
|
const mockGetInitData = vi.fn();
|
||||||
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
|
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";
|
import { state } from "./dom.js";
|
||||||
|
|
||||||
describe("buildFetchOptions", () => {
|
describe("buildFetchOptions", () => {
|
||||||
@@ -102,3 +102,111 @@ describe("fetchDuties", () => {
|
|||||||
).rejects.toMatchObject({ name: "AbortError" });
|
).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,
|
getTgWebAppDataFromHash,
|
||||||
getInitData,
|
getInitData,
|
||||||
isLocalhost,
|
isLocalhost,
|
||||||
|
hasTelegramHashButNoInitData,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
|
||||||
describe("getTgWebAppDataFromHash", () => {
|
describe("getTgWebAppDataFromHash", () => {
|
||||||
@@ -98,3 +99,57 @@ describe("isLocalhost", () => {
|
|||||||
expect(isLocalhost()).toBe(false);
|
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.
|
* 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 { monthName, t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,8 @@ export function renderCalendar(
|
|||||||
dutiesByDateMap,
|
dutiesByDateMap,
|
||||||
calendarEventsByDateMap
|
calendarEventsByDateMap
|
||||||
) {
|
) {
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
|
const monthTitleEl = getMonthTitleEl();
|
||||||
if (!calendarEl || !monthTitleEl) return;
|
if (!calendarEl || !monthTitleEl) return;
|
||||||
const first = firstDayOfMonth(new Date(year, month, 1));
|
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||||
const last = lastDayOfMonth(new Date(year, month, 1));
|
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* calendarEventsByDate.
|
* calendarEventsByDate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
document.body.innerHTML =
|
document.body.innerHTML =
|
||||||
@@ -13,7 +13,8 @@ beforeAll(() => {
|
|||||||
'<button id="prevMonth"></button><button id="nextMonth"></button>';
|
'<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", () => {
|
describe("dutiesByDate", () => {
|
||||||
it("groups duty by single local day", () => {
|
it("groups duty by single local day", () => {
|
||||||
@@ -93,3 +94,46 @@ describe("calendarEventsByDate", () => {
|
|||||||
expect(calendarEventsByDate(undefined)).toEqual({});
|
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.
|
* 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 { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
|
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||||
import { fetchDuties } from "./api.js";
|
import { fetchDuties } from "./api.js";
|
||||||
import {
|
import {
|
||||||
localDateString,
|
localDateString,
|
||||||
@@ -13,6 +14,11 @@ import {
|
|||||||
formatHHMM
|
formatHHMM
|
||||||
} from "./dateUtils.js";
|
} 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".
|
* 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
|
* @param {object[]} duties - List of duties with start_at, end_at, event_type
|
||||||
@@ -30,54 +36,6 @@ export function findCurrentDuty(duties) {
|
|||||||
return null;
|
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).
|
* Render the current duty view content (card with duty or no-duty message).
|
||||||
* @param {object|null} duty - Active duty or null
|
* @param {object|null} duty - Active duty or null
|
||||||
@@ -120,7 +78,11 @@ export function renderCurrentDutyContent(duty, lang) {
|
|||||||
" " +
|
" " +
|
||||||
endTime;
|
endTime;
|
||||||
const shiftLabel = t(lang, "current_duty.shift");
|
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 (
|
return (
|
||||||
'<div class="current-duty-card">' +
|
'<div class="current-duty-card">' +
|
||||||
@@ -149,16 +111,18 @@ export function renderCurrentDutyContent(duty, lang) {
|
|||||||
* @param {() => void} onBack - Callback when user taps "Back to calendar"
|
* @param {() => void} onBack - Callback when user taps "Back to calendar"
|
||||||
*/
|
*/
|
||||||
export async function showCurrentDutyView(onBack) {
|
export async function showCurrentDutyView(onBack) {
|
||||||
|
const currentDutyViewEl = getCurrentDutyViewEl();
|
||||||
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
||||||
const calendarSticky = document.getElementById("calendarSticky");
|
const calendarSticky = document.getElementById("calendarSticky");
|
||||||
const dutyList = document.getElementById("dutyList");
|
const dutyList = document.getElementById("dutyList");
|
||||||
if (!currentDutyViewEl) return;
|
if (!currentDutyViewEl) return;
|
||||||
|
|
||||||
currentDutyViewEl._onBack = onBack;
|
onBackCallback = onBack;
|
||||||
currentDutyViewEl.classList.remove("hidden");
|
currentDutyViewEl.classList.remove("hidden");
|
||||||
if (container) container.setAttribute("data-view", "currentDuty");
|
if (container) container.setAttribute("data-view", "currentDuty");
|
||||||
if (calendarSticky) calendarSticky.hidden = true;
|
if (calendarSticky) calendarSticky.hidden = true;
|
||||||
if (dutyList) dutyList.hidden = true;
|
if (dutyList) dutyList.hidden = true;
|
||||||
|
const loadingEl = getLoadingEl();
|
||||||
if (loadingEl) loadingEl.classList.add("hidden");
|
if (loadingEl) loadingEl.classList.add("hidden");
|
||||||
|
|
||||||
const lang = state.lang;
|
const lang = state.lang;
|
||||||
@@ -170,9 +134,9 @@ export async function showCurrentDutyView(onBack) {
|
|||||||
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
||||||
window.Telegram.WebApp.BackButton.show();
|
window.Telegram.WebApp.BackButton.show();
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
if (currentDutyViewEl._onBack) currentDutyViewEl._onBack();
|
if (onBackCallback) onBackCallback();
|
||||||
};
|
};
|
||||||
currentDutyViewEl._backButtonHandler = handler;
|
backButtonHandler = handler;
|
||||||
window.Telegram.WebApp.BackButton.onClick(handler);
|
window.Telegram.WebApp.BackButton.onClick(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +169,7 @@ export async function showCurrentDutyView(onBack) {
|
|||||||
function handleCurrentDutyClick(e) {
|
function handleCurrentDutyClick(e) {
|
||||||
const btn = e.target && e.target.closest("[data-action='back']");
|
const btn = e.target && e.target.closest("[data-action='back']");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (currentDutyViewEl && currentDutyViewEl._onBack) {
|
if (onBackCallback) onBackCallback();
|
||||||
currentDutyViewEl._onBack();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,24 +177,24 @@ function handleCurrentDutyClick(e) {
|
|||||||
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
|
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
|
||||||
*/
|
*/
|
||||||
export function hideCurrentDutyView() {
|
export function hideCurrentDutyView() {
|
||||||
|
const currentDutyViewEl = getCurrentDutyViewEl();
|
||||||
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
|
||||||
const calendarSticky = document.getElementById("calendarSticky");
|
const calendarSticky = document.getElementById("calendarSticky");
|
||||||
const dutyList = document.getElementById("dutyList");
|
const dutyList = document.getElementById("dutyList");
|
||||||
const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler;
|
|
||||||
|
|
||||||
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
|
||||||
if (backHandler) {
|
if (backButtonHandler) {
|
||||||
window.Telegram.WebApp.BackButton.offClick(backHandler);
|
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
|
||||||
}
|
}
|
||||||
window.Telegram.WebApp.BackButton.hide();
|
window.Telegram.WebApp.BackButton.hide();
|
||||||
}
|
}
|
||||||
if (currentDutyViewEl) {
|
if (currentDutyViewEl) {
|
||||||
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
|
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
|
||||||
currentDutyViewEl._onBack = null;
|
|
||||||
currentDutyViewEl._backButtonHandler = null;
|
|
||||||
currentDutyViewEl.classList.add("hidden");
|
currentDutyViewEl.classList.add("hidden");
|
||||||
currentDutyViewEl.innerHTML = "";
|
currentDutyViewEl.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
onBackCallback = null;
|
||||||
|
backButtonHandler = null;
|
||||||
if (container) container.removeAttribute("data-view");
|
if (container) container.removeAttribute("data-view");
|
||||||
if (calendarSticky) calendarSticky.hidden = false;
|
if (calendarSticky) calendarSticky.hidden = false;
|
||||||
if (dutyList) dutyList.hidden = false;
|
if (dutyList) dutyList.hidden = false;
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
dutyOverlapsLocalRange,
|
dutyOverlapsLocalRange,
|
||||||
getMonday,
|
getMonday,
|
||||||
formatHHMM,
|
formatHHMM,
|
||||||
|
firstDayOfMonth,
|
||||||
|
lastDayOfMonth,
|
||||||
|
formatDateKey,
|
||||||
|
dateKeyToDDMM,
|
||||||
} from "./dateUtils.js";
|
} from "./dateUtils.js";
|
||||||
|
|
||||||
describe("localDateString", () => {
|
describe("localDateString", () => {
|
||||||
@@ -157,3 +161,70 @@ describe("formatHHMM", () => {
|
|||||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
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.
|
* 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 { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
|
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||||
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
|
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
|
||||||
import { getDutyMarkerRows } from "./hints.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.
|
* Build HTML content for the day detail panel.
|
||||||
* @param {string} dateKey - YYYY-MM-DD
|
* @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 phone = r.phone != null ? r.phone : (duty && duty.phone);
|
||||||
const username = r.username != null ? r.username : (duty && duty.username);
|
const username = r.username != null ? r.username : (duty && duty.username);
|
||||||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
|
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 +=
|
html +=
|
||||||
"<li>" +
|
"<li>" +
|
||||||
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
||||||
@@ -199,6 +167,7 @@ function positionPopover(panel, cellRect) {
|
|||||||
const panelRect = panel.getBoundingClientRect();
|
const panelRect = panel.getBoundingClientRect();
|
||||||
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
|
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
|
||||||
let top = cellRect.bottom + 8;
|
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) {
|
if (top + panelRect.height > vh - margin) {
|
||||||
top = cellRect.top - panelRect.height - 8;
|
top = cellRect.top - panelRect.height - 8;
|
||||||
panel.classList.add("day-detail-panel--below");
|
panel.classList.add("day-detail-panel--below");
|
||||||
@@ -256,6 +225,7 @@ function showAsPopover(cellRect) {
|
|||||||
const target = e.target instanceof Node ? e.target : null;
|
const target = e.target instanceof Node ? e.target : null;
|
||||||
if (!target || !panelEl) return;
|
if (!target || !panelEl) return;
|
||||||
if (panelEl.contains(target)) return;
|
if (panelEl.contains(target)) return;
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
|
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
|
||||||
hideDayDetail();
|
hideDayDetail();
|
||||||
};
|
};
|
||||||
@@ -390,6 +360,7 @@ function ensurePanelInDom() {
|
|||||||
* Bind delegated click/keydown on calendar for .day cells.
|
* Bind delegated click/keydown on calendar for .day cells.
|
||||||
*/
|
*/
|
||||||
export function initDayDetail() {
|
export function initDayDetail() {
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
if (!calendarEl) return;
|
if (!calendarEl) return;
|
||||||
calendarEl.addEventListener("click", (e) => {
|
calendarEl.addEventListener("click", (e) => {
|
||||||
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);
|
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);
|
||||||
|
|||||||
@@ -1,39 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* DOM references and shared application state.
|
* 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} */
|
/** @returns {HTMLDivElement|null} */
|
||||||
export const calendarEl = document.getElementById("calendar");
|
export function getCalendarEl() {
|
||||||
|
return document.getElementById("calendar");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const monthTitleEl = document.getElementById("monthTitle");
|
export function getMonthTitleEl() {
|
||||||
|
return document.getElementById("monthTitle");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLDivElement|null} */
|
/** @returns {HTMLDivElement|null} */
|
||||||
export const dutyListEl = document.getElementById("dutyList");
|
export function getDutyListEl() {
|
||||||
|
return document.getElementById("dutyList");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const loadingEl = document.getElementById("loading");
|
export function getLoadingEl() {
|
||||||
|
return document.getElementById("loading");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const errorEl = document.getElementById("error");
|
export function getErrorEl() {
|
||||||
|
return document.getElementById("error");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const accessDeniedEl = document.getElementById("accessDenied");
|
export function getAccessDeniedEl() {
|
||||||
|
return document.getElementById("accessDenied");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const headerEl = document.querySelector(".header");
|
export function getHeaderEl() {
|
||||||
|
return document.querySelector(".header");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @returns {HTMLElement|null} */
|
||||||
export const weekdaysEl = document.querySelector(".weekdays");
|
export function getWeekdaysEl() {
|
||||||
|
return document.querySelector(".weekdays");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLButtonElement|null} */
|
/** @returns {HTMLButtonElement|null} */
|
||||||
export const prevBtn = document.getElementById("prevMonth");
|
export function getPrevBtn() {
|
||||||
|
return document.getElementById("prevMonth");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLButtonElement|null} */
|
/** @returns {HTMLButtonElement|null} */
|
||||||
export const nextBtn = document.getElementById("nextMonth");
|
export function getNextBtn() {
|
||||||
|
return document.getElementById("nextMonth");
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {HTMLDivElement|null} */
|
/** @returns {HTMLDivElement|null} */
|
||||||
export const currentDutyViewEl = document.getElementById("currentDutyView");
|
export function getCurrentDutyViewEl() {
|
||||||
|
return document.getElementById("currentDutyView");
|
||||||
|
}
|
||||||
|
|
||||||
/** Currently viewed month (mutable). */
|
/** Currently viewed month (mutable). */
|
||||||
export const state = {
|
export const state = {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* Duty list (timeline) rendering.
|
* Duty list (timeline) rendering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dutyListEl, state } from "./dom.js";
|
import { getDutyListEl, state } from "./dom.js";
|
||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
|
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||||
import {
|
import {
|
||||||
localDateString,
|
localDateString,
|
||||||
firstDayOfMonth,
|
firstDayOfMonth,
|
||||||
@@ -14,37 +15,6 @@ import {
|
|||||||
formatDateKey
|
formatDateKey
|
||||||
} from "./dateUtils.js";
|
} 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). */
|
/** Phone icon SVG for flip button (show contacts). */
|
||||||
const ICON_PHONE =
|
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>';
|
'<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, "duty.now_on_duty")
|
||||||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||||||
const extraClass = isCurrent ? " duty-item--current" : "";
|
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(
|
const hasContacts = Boolean(
|
||||||
(d.phone && String(d.phone).trim()) ||
|
(d.phone && String(d.phone).trim()) ||
|
||||||
(d.username && String(d.username).trim())
|
(d.username && String(d.username).trim())
|
||||||
@@ -174,12 +148,12 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
|||||||
'</span> <span class="name">' +
|
'</span> <span class="name">' +
|
||||||
escapeHtml(d.full_name) +
|
escapeHtml(d.full_name) +
|
||||||
'</span><div class="time">' +
|
'</span><div class="time">' +
|
||||||
timeOrRange +
|
escapeHtml(timeOrRange) +
|
||||||
"</div></div>"
|
"</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;
|
let flipListenerAttached = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +161,7 @@ let flipListenerAttached = false;
|
|||||||
* @param {object[]} duties - Duties (only duty type used for timeline)
|
* @param {object[]} duties - Duties (only duty type used for timeline)
|
||||||
*/
|
*/
|
||||||
export function renderDutyList(duties) {
|
export function renderDutyList(duties) {
|
||||||
|
const dutyListEl = getDutyListEl();
|
||||||
if (!dutyListEl) return;
|
if (!dutyListEl) return;
|
||||||
|
|
||||||
if (!flipListenerAttached) {
|
if (!flipListenerAttached) {
|
||||||
@@ -277,8 +252,9 @@ export function renderDutyList(duties) {
|
|||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
|
const listEl = getDutyListEl();
|
||||||
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
|
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
|
||||||
|
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
|
||||||
if (currentDutyCard) {
|
if (currentDutyCard) {
|
||||||
scrollToEl(currentDutyCard);
|
scrollToEl(currentDutyCard);
|
||||||
} else if (todayBlock) {
|
} 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 { describe, it, expect, beforeAll, vi, afterEach } from "vitest";
|
||||||
import { dutyTimelineCardHtml } from "./dutyList.js";
|
import * as dateUtils from "./dateUtils.js";
|
||||||
|
import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js";
|
||||||
|
|
||||||
describe("dutyList", () => {
|
describe("dutyList", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -89,4 +90,75 @@ describe("dutyList", () => {
|
|||||||
expect(html).not.toContain("duty-contact-row");
|
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.
|
* 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 { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
import { localDateString, formatHHMM } from "./dateUtils.js";
|
import { localDateString, formatHHMM } from "./dateUtils.js";
|
||||||
@@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
|
|||||||
* Remove active class from all duty/unavailable/vacation markers.
|
* Remove active class from all duty/unavailable/vacation markers.
|
||||||
*/
|
*/
|
||||||
export function clearActiveDutyMarker() {
|
export function clearActiveDutyMarker() {
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
if (!calendarEl) return;
|
if (!calendarEl) return;
|
||||||
calendarEl
|
calendarEl
|
||||||
.querySelectorAll(
|
.querySelectorAll(
|
||||||
@@ -261,6 +262,25 @@ export function clearActiveDutyMarker() {
|
|||||||
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
|
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
|
||||||
let dutyMarkerHideTimeout = null;
|
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";
|
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,6 +324,7 @@ function getOrCreateDutyMarkerHint() {
|
|||||||
export function initHints() {
|
export function initHints() {
|
||||||
const calendarEventHint = getOrCreateCalendarEventHint();
|
const calendarEventHint = getOrCreateCalendarEventHint();
|
||||||
const dutyMarkerHint = getOrCreateDutyMarkerHint();
|
const dutyMarkerHint = getOrCreateDutyMarkerHint();
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
if (!calendarEl) return;
|
if (!calendarEl) return;
|
||||||
|
|
||||||
calendarEl.addEventListener("click", (e) => {
|
calendarEl.addEventListener("click", (e) => {
|
||||||
@@ -317,11 +338,7 @@ export function initHints() {
|
|||||||
positionHint(calendarEventHint, btn.getBoundingClientRect());
|
positionHint(calendarEventHint, btn.getBoundingClientRect());
|
||||||
calendarEventHint.dataset.active = "1";
|
calendarEventHint.dataset.active = "1";
|
||||||
} else {
|
} else {
|
||||||
calendarEventHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(calendarEventHint);
|
||||||
setTimeout(() => {
|
|
||||||
calendarEventHint.hidden = true;
|
|
||||||
calendarEventHint.removeAttribute("data-active");
|
|
||||||
}, 150);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -330,11 +347,7 @@ export function initHints() {
|
|||||||
if (marker) {
|
if (marker) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (marker.classList.contains("calendar-marker-active")) {
|
if (marker.classList.contains("calendar-marker-active")) {
|
||||||
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(dutyMarkerHint);
|
||||||
setTimeout(() => {
|
|
||||||
dutyMarkerHint.hidden = true;
|
|
||||||
dutyMarkerHint.removeAttribute("data-active");
|
|
||||||
}, 150);
|
|
||||||
marker.classList.remove("calendar-marker-active");
|
marker.classList.remove("calendar-marker-active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -377,31 +390,23 @@ export function initHints() {
|
|||||||
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
|
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
|
||||||
if (toMarker) return;
|
if (toMarker) return;
|
||||||
if (dutyMarkerHint.dataset.active) return;
|
if (dutyMarkerHint.dataset.active) return;
|
||||||
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
|
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
|
||||||
dutyMarkerHideTimeout = setTimeout(() => {
|
afterHide: () => {
|
||||||
dutyMarkerHint.hidden = true;
|
|
||||||
dutyMarkerHideTimeout = null;
|
dutyMarkerHideTimeout = null;
|
||||||
}, 150);
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!state.calendarHintBound) {
|
if (!state.calendarHintBound) {
|
||||||
state.calendarHintBound = true;
|
state.calendarHintBound = true;
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", () => {
|
||||||
if (calendarEventHint.dataset.active) {
|
if (calendarEventHint.dataset.active) {
|
||||||
calendarEventHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(calendarEventHint);
|
||||||
setTimeout(() => {
|
|
||||||
calendarEventHint.hidden = true;
|
|
||||||
calendarEventHint.removeAttribute("data-active");
|
|
||||||
}, 150);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && calendarEventHint.dataset.active) {
|
if (e.key === "Escape" && calendarEventHint.dataset.active) {
|
||||||
calendarEventHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(calendarEventHint);
|
||||||
setTimeout(() => {
|
|
||||||
calendarEventHint.hidden = true;
|
|
||||||
calendarEventHint.removeAttribute("data-active");
|
|
||||||
}, 150);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -410,22 +415,12 @@ export function initHints() {
|
|||||||
state.dutyMarkerHintBound = true;
|
state.dutyMarkerHintBound = true;
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", () => {
|
||||||
if (dutyMarkerHint.dataset.active) {
|
if (dutyMarkerHint.dataset.active) {
|
||||||
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(dutyMarkerHint, { clearActive: true });
|
||||||
setTimeout(() => {
|
|
||||||
dutyMarkerHint.hidden = true;
|
|
||||||
dutyMarkerHint.removeAttribute("data-active");
|
|
||||||
clearActiveDutyMarker();
|
|
||||||
}, 150);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
|
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
|
||||||
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
|
dismissHint(dutyMarkerHint, { clearActive: true });
|
||||||
setTimeout(() => {
|
|
||||||
dutyMarkerHint.hidden = true;
|
|
||||||
dutyMarkerHint.removeAttribute("data-active");
|
|
||||||
clearActiveDutyMarker();
|
|
||||||
}, 150);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
|
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
|
||||||
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
|
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
|
||||||
|
* Also tests dismissHint helper.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { getDutyMarkerRows } from "./hints.js";
|
import { getDutyMarkerRows, dismissHint } from "./hints.js";
|
||||||
|
|
||||||
const FROM = "from";
|
const FROM = "from";
|
||||||
const TO = "until";
|
const TO = "until";
|
||||||
@@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => {
|
|||||||
expect(rows[2].timePrefix).toContain("15:00");
|
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 { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
|
||||||
import {
|
import {
|
||||||
state,
|
state,
|
||||||
accessDeniedEl,
|
getAccessDeniedEl,
|
||||||
prevBtn,
|
getPrevBtn,
|
||||||
nextBtn,
|
getNextBtn,
|
||||||
loadingEl,
|
getLoadingEl,
|
||||||
errorEl,
|
getErrorEl,
|
||||||
weekdaysEl
|
getWeekdaysEl
|
||||||
} from "./dom.js";
|
} from "./dom.js";
|
||||||
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
||||||
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
||||||
@@ -39,15 +39,19 @@ initTheme();
|
|||||||
state.lang = getLang();
|
state.lang = getLang();
|
||||||
document.documentElement.lang = state.lang;
|
document.documentElement.lang = state.lang;
|
||||||
document.title = t(state.lang, "app.title");
|
document.title = t(state.lang, "app.title");
|
||||||
|
const loadingEl = getLoadingEl();
|
||||||
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
|
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
|
||||||
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
|
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
|
||||||
const dayLabels = weekdayLabels(state.lang);
|
const dayLabels = weekdayLabels(state.lang);
|
||||||
|
const weekdaysEl = getWeekdaysEl();
|
||||||
if (weekdaysEl) {
|
if (weekdaysEl) {
|
||||||
const spans = weekdaysEl.querySelectorAll("span");
|
const spans = weekdaysEl.querySelectorAll("span");
|
||||||
spans.forEach((span, i) => {
|
spans.forEach((span, i) => {
|
||||||
if (dayLabels[i]) span.textContent = dayLabels[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 (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
|
||||||
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
|
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
|
||||||
|
|
||||||
@@ -99,12 +103,14 @@ function requireTelegramOrLocalhost(onAllowed) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showAccessDenied(undefined);
|
showAccessDenied(undefined);
|
||||||
if (loadingEl) loadingEl.classList.add("hidden");
|
const loading = getLoadingEl();
|
||||||
|
if (loading) loading.classList.add("hidden");
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showAccessDenied(undefined);
|
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. */
|
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
|
||||||
@@ -121,7 +127,9 @@ async function loadMonth() {
|
|||||||
|
|
||||||
hideAccessDenied();
|
hideAccessDenied();
|
||||||
setNavEnabled(false);
|
setNavEnabled(false);
|
||||||
|
const loadingEl = getLoadingEl();
|
||||||
if (loadingEl) loadingEl.classList.remove("hidden");
|
if (loadingEl) loadingEl.classList.remove("hidden");
|
||||||
|
const errorEl = getErrorEl();
|
||||||
if (errorEl) errorEl.hidden = true;
|
if (errorEl) errorEl.hidden = true;
|
||||||
const current = state.current;
|
const current = state.current;
|
||||||
const first = firstDayOfMonth(current);
|
const first = firstDayOfMonth(current);
|
||||||
@@ -185,21 +193,26 @@ async function loadMonth() {
|
|||||||
setNavEnabled(true);
|
setNavEnabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (loadingEl) loadingEl.classList.add("hidden");
|
const loading = getLoadingEl();
|
||||||
|
if (loading) loading.classList.add("hidden");
|
||||||
setNavEnabled(true);
|
setNavEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevBtn) {
|
const prevBtnEl = getPrevBtn();
|
||||||
prevBtn.addEventListener("click", () => {
|
if (prevBtnEl) {
|
||||||
|
prevBtnEl.addEventListener("click", () => {
|
||||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||||
|
const accessDeniedEl = getAccessDeniedEl();
|
||||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||||
state.current.setMonth(state.current.getMonth() - 1);
|
state.current.setMonth(state.current.getMonth() - 1);
|
||||||
loadMonth();
|
loadMonth();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (nextBtn) {
|
const nextBtnEl = getNextBtn();
|
||||||
nextBtn.addEventListener("click", () => {
|
if (nextBtnEl) {
|
||||||
|
nextBtnEl.addEventListener("click", () => {
|
||||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||||
|
const accessDeniedEl = getAccessDeniedEl();
|
||||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||||
state.current.setMonth(state.current.getMonth() + 1);
|
state.current.setMonth(state.current.getMonth() + 1);
|
||||||
loadMonth();
|
loadMonth();
|
||||||
@@ -227,12 +240,15 @@ if (nextBtn) {
|
|||||||
(e) => {
|
(e) => {
|
||||||
if (e.changedTouches.length === 0) return;
|
if (e.changedTouches.length === 0) return;
|
||||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||||
|
const accessDeniedEl = getAccessDeniedEl();
|
||||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||||
const touch = e.changedTouches[0];
|
const touch = e.changedTouches[0];
|
||||||
const deltaX = touch.clientX - startX;
|
const deltaX = touch.clientX - startX;
|
||||||
const deltaY = touch.clientY - startY;
|
const deltaY = touch.clientY - startY;
|
||||||
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
|
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
|
||||||
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
||||||
|
const prevBtn = getPrevBtn();
|
||||||
|
const nextBtn = getNextBtn();
|
||||||
if (deltaX > SWIPE_THRESHOLD) {
|
if (deltaX > SWIPE_THRESHOLD) {
|
||||||
if (prevBtn && prevBtn.disabled) return;
|
if (prevBtn && prevBtn.disabled) return;
|
||||||
state.current.setMonth(state.current.getMonth() - 1);
|
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 {
|
import {
|
||||||
state,
|
state,
|
||||||
calendarEl,
|
getCalendarEl,
|
||||||
dutyListEl,
|
getDutyListEl,
|
||||||
loadingEl,
|
getLoadingEl,
|
||||||
errorEl,
|
getErrorEl,
|
||||||
accessDeniedEl,
|
getAccessDeniedEl,
|
||||||
headerEl,
|
getHeaderEl,
|
||||||
weekdaysEl,
|
getWeekdaysEl,
|
||||||
prevBtn,
|
getPrevBtn,
|
||||||
nextBtn
|
getNextBtn
|
||||||
} from "./dom.js";
|
} from "./dom.js";
|
||||||
import { t } from "./i18n.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)
|
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
|
||||||
*/
|
*/
|
||||||
export function showAccessDenied(serverDetail) {
|
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 (headerEl) headerEl.hidden = true;
|
||||||
if (weekdaysEl) weekdaysEl.hidden = true;
|
if (weekdaysEl) weekdaysEl.hidden = true;
|
||||||
if (calendarEl) calendarEl.hidden = true;
|
if (calendarEl) calendarEl.hidden = true;
|
||||||
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
|
|||||||
* Hide access-denied and show calendar/list/header/weekdays.
|
* Hide access-denied and show calendar/list/header/weekdays.
|
||||||
*/
|
*/
|
||||||
export function hideAccessDenied() {
|
export function hideAccessDenied() {
|
||||||
|
const accessDeniedEl = getAccessDeniedEl();
|
||||||
|
const headerEl = getHeaderEl();
|
||||||
|
const weekdaysEl = getWeekdaysEl();
|
||||||
|
const calendarEl = getCalendarEl();
|
||||||
|
const dutyListEl = getDutyListEl();
|
||||||
if (accessDeniedEl) accessDeniedEl.hidden = true;
|
if (accessDeniedEl) accessDeniedEl.hidden = true;
|
||||||
if (headerEl) headerEl.hidden = false;
|
if (headerEl) headerEl.hidden = false;
|
||||||
if (weekdaysEl) weekdaysEl.hidden = false;
|
if (weekdaysEl) weekdaysEl.hidden = false;
|
||||||
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
|
|||||||
* @param {string} msg - Error text
|
* @param {string} msg - Error text
|
||||||
*/
|
*/
|
||||||
export function showError(msg) {
|
export function showError(msg) {
|
||||||
|
const errorEl = getErrorEl();
|
||||||
|
const loadingEl = getLoadingEl();
|
||||||
if (errorEl) {
|
if (errorEl) {
|
||||||
errorEl.textContent = msg;
|
errorEl.textContent = msg;
|
||||||
errorEl.hidden = false;
|
errorEl.hidden = false;
|
||||||
@@ -68,6 +82,8 @@ export function showError(msg) {
|
|||||||
* @param {boolean} enabled
|
* @param {boolean} enabled
|
||||||
*/
|
*/
|
||||||
export function setNavEnabled(enabled) {
|
export function setNavEnabled(enabled) {
|
||||||
|
const prevBtn = getPrevBtn();
|
||||||
|
const nextBtn = getNextBtn();
|
||||||
if (prevBtn) prevBtn.disabled = !enabled;
|
if (prevBtn) prevBtn.disabled = !enabled;
|
||||||
if (nextBtn) nextBtn.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