diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd2057d..6f76f5d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -39,3 +39,14 @@ jobs: - name: Security check with Bandit run: | bandit -r duty_teller -ll + + - name: Set up Node.js + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: "20" + + - name: Webapp tests + run: | + cd webapp + npm ci + npm run test diff --git a/webapp/css/base.css b/webapp/css/base.css new file mode 100644 index 0000000..6eea6c4 --- /dev/null +++ b/webapp/css/base.css @@ -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; +} diff --git a/webapp/css/calendar.css b/webapp/css/calendar.css new file mode 100644 index 0000000..1287c4f --- /dev/null +++ b/webapp/css/calendar.css @@ -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)); +} diff --git a/webapp/css/day-detail.css b/webapp/css/day-detail.css new file mode 100644 index 0000000..3547aca --- /dev/null +++ b/webapp/css/day-detail.css @@ -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; +} diff --git a/webapp/css/duty-list.css b/webapp/css/duty-list.css new file mode 100644 index 0000000..e679bfe --- /dev/null +++ b/webapp/css/duty-list.css @@ -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)); +} diff --git a/webapp/css/hints.css b/webapp/css/hints.css new file mode 100644 index 0000000..c1ff8a7 --- /dev/null +++ b/webapp/css/hints.css @@ -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; +} diff --git a/webapp/css/markers.css b/webapp/css/markers.css new file mode 100644 index 0000000..35bb25d --- /dev/null +++ b/webapp/css/markers.css @@ -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); +} diff --git a/webapp/css/states.css b/webapp/css/states.css new file mode 100644 index 0000000..8273fda --- /dev/null +++ b/webapp/css/states.css @@ -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; +} diff --git a/webapp/index.html b/webapp/index.html index 578acf0..d890b32 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -5,7 +5,13 @@ - + + + + + + +
diff --git a/webapp/js/api.js b/webapp/js/api.js index 89558ac..4204988 100644 --- a/webapp/js/api.js +++ b/webapp/js/api.js @@ -67,31 +67,26 @@ export async function apiGet(path, params = {}, options = {}) { * @returns {Promise} */ export async function fetchDuties(from, to, signal) { - try { - const res = await apiGet("/api/duties", { from, to }, { signal }); - if (res.status === 403) { - let detail = t(state.lang, "access_denied"); - try { - const body = await res.json(); - if (body && body.detail !== undefined) { - detail = - typeof body.detail === "string" - ? body.detail - : (body.detail.msg || JSON.stringify(body.detail)); - } - } catch (parseErr) { - /* ignore */ + const res = await apiGet("/api/duties", { from, to }, { signal }); + if (res.status === 403) { + let detail = t(state.lang, "access_denied"); + try { + const body = await res.json(); + if (body && body.detail !== undefined) { + detail = + typeof body.detail === "string" + ? body.detail + : (body.detail.msg || JSON.stringify(body.detail)); } - const err = new Error("ACCESS_DENIED"); - err.serverDetail = detail; - throw err; + } catch (parseErr) { + /* ignore */ } - if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); - return res.json(); - } catch (e) { - if (e.name === "AbortError") throw e; - throw e; + const err = new Error("ACCESS_DENIED"); + err.serverDetail = detail; + throw err; } + if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); + return res.json(); } /** diff --git a/webapp/js/api.test.js b/webapp/js/api.test.js index 8038621..618429f 100644 --- a/webapp/js/api.test.js +++ b/webapp/js/api.test.js @@ -15,7 +15,7 @@ beforeAll(() => { const mockGetInitData = vi.fn(); vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() })); -import { buildFetchOptions, fetchDuties } from "./api.js"; +import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js"; import { state } from "./dom.js"; describe("buildFetchOptions", () => { @@ -102,3 +102,111 @@ describe("fetchDuties", () => { ).rejects.toMatchObject({ name: "AbortError" }); }); }); + +describe("apiGet", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetInitData.mockReturnValue("init-data"); + state.lang = "en"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("builds URL with path and query params and returns response", async () => { + let capturedUrl = ""; + globalThis.fetch = vi.fn().mockImplementation((url) => { + capturedUrl = url; + return Promise.resolve({ ok: true, status: 200 }); + }); + await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" }); + expect(capturedUrl).toContain("/api/duties"); + expect(capturedUrl).toContain("from=2025-02-01"); + expect(capturedUrl).toContain("to=2025-02-28"); + }); + + it("omits query string when params empty", async () => { + let capturedUrl = ""; + globalThis.fetch = vi.fn().mockImplementation((url) => { + capturedUrl = url; + return Promise.resolve({ ok: true }); + }); + await apiGet("/api/health", {}); + expect(capturedUrl).toBe(window.location.origin + "/api/health"); + }); + + it("passes X-Telegram-Init-Data and Accept-Language headers", async () => { + let capturedOpts = null; + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + capturedOpts = opts; + return Promise.resolve({ ok: true }); + }); + await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" }); + expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data"); + expect(capturedOpts?.headers["Accept-Language"]).toBe("en"); + }); + + it("passes an abort signal to fetch when options.signal provided", async () => { + const controller = new AbortController(); + let capturedSignal = null; + globalThis.fetch = vi.fn().mockImplementation((url, opts) => { + capturedSignal = opts.signal; + return Promise.resolve({ ok: true }); + }); + await apiGet("/api/duties", {}, { signal: controller.signal }); + expect(capturedSignal).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); +}); + +describe("fetchCalendarEvents", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetInitData.mockReturnValue("init-data"); + state.lang = "ru"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns JSON array on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]), + }); + const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); + expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]); + }); + + it("returns empty array on non-OK response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); + expect(result).toEqual([]); + }); + + it("returns empty array on 403 (does not throw)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + }); + const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); + expect(result).toEqual([]); + }); + + it("rethrows AbortError when request is aborted", async () => { + const aborter = new AbortController(); + const abortError = new DOMException("aborted", "AbortError"); + globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError)); + await expect( + fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/webapp/js/auth.test.js b/webapp/js/auth.test.js index e614e49..8b1d1aa 100644 --- a/webapp/js/auth.test.js +++ b/webapp/js/auth.test.js @@ -7,6 +7,7 @@ import { getTgWebAppDataFromHash, getInitData, isLocalhost, + hasTelegramHashButNoInitData, } from "./auth.js"; describe("getTgWebAppDataFromHash", () => { @@ -98,3 +99,57 @@ describe("isLocalhost", () => { expect(isLocalhost()).toBe(false); }); }); + +describe("hasTelegramHashButNoInitData", () => { + const origLocation = window.location; + + afterEach(() => { + window.location = origLocation; + }); + + it("returns false when hash is empty", () => { + delete window.location; + window.location = { ...origLocation, hash: "", search: "" }; + expect(hasTelegramHashButNoInitData()).toBe(false); + }); + + it("returns true when hash has tgWebAppVersion but no tgWebAppData", () => { + delete window.location; + window.location = { + ...origLocation, + hash: "#tgWebAppVersion=6", + search: "", + }; + expect(hasTelegramHashButNoInitData()).toBe(true); + }); + + it("returns false when hash has both tgWebAppVersion and tgWebAppData", () => { + delete window.location; + window.location = { + ...origLocation, + hash: "#tgWebAppVersion=6&tgWebAppData=some%3Ddata", + search: "", + }; + expect(hasTelegramHashButNoInitData()).toBe(false); + }); + + it("returns false when hash has tgWebAppData in unencoded form (with & and =)", () => { + delete window.location; + window.location = { + ...origLocation, + hash: "#tgWebAppData=value&tgWebAppVersion=6", + search: "", + }; + expect(hasTelegramHashButNoInitData()).toBe(false); + }); + + it("returns false when hash has no Telegram params", () => { + delete window.location; + window.location = { + ...origLocation, + hash: "#other=param", + search: "", + }; + expect(hasTelegramHashButNoInitData()).toBe(false); + }); +}); diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js index 7597886..e220644 100644 --- a/webapp/js/calendar.js +++ b/webapp/js/calendar.js @@ -2,7 +2,7 @@ * Calendar grid and events-by-date mapping. */ -import { calendarEl, monthTitleEl, state } from "./dom.js"; +import { getCalendarEl, getMonthTitleEl, state } from "./dom.js"; import { monthName, t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; import { @@ -71,6 +71,8 @@ export function renderCalendar( dutiesByDateMap, calendarEventsByDateMap ) { + const calendarEl = getCalendarEl(); + const monthTitleEl = getMonthTitleEl(); if (!calendarEl || !monthTitleEl) return; const first = firstDayOfMonth(new Date(year, month, 1)); const last = lastDayOfMonth(new Date(year, month, 1)); diff --git a/webapp/js/calendar.test.js b/webapp/js/calendar.test.js index ce85a97..a1a99bf 100644 --- a/webapp/js/calendar.test.js +++ b/webapp/js/calendar.test.js @@ -3,7 +3,7 @@ * calendarEventsByDate. */ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; beforeAll(() => { document.body.innerHTML = @@ -13,7 +13,8 @@ beforeAll(() => { ''; }); -import { dutiesByDate, calendarEventsByDate } from "./calendar.js"; +import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js"; +import { state } from "./dom.js"; describe("dutiesByDate", () => { it("groups duty by single local day", () => { @@ -93,3 +94,46 @@ describe("calendarEventsByDate", () => { expect(calendarEventsByDate(undefined)).toEqual({}); }); }); + +describe("renderCalendar", () => { + beforeEach(() => { + state.lang = "en"; + }); + + it("renders 42 cells (6 weeks)", () => { + renderCalendar(2025, 0, {}, {}); + const calendarEl = document.getElementById("calendar"); + const cells = calendarEl?.querySelectorAll(".day") ?? []; + expect(cells.length).toBe(42); + }); + + it("sets data-date on each cell to YYYY-MM-DD", () => { + renderCalendar(2025, 0, {}, {}); + const calendarEl = document.getElementById("calendar"); + const cells = Array.from(calendarEl?.querySelectorAll(".day") ?? []); + const dates = cells.map((c) => c.getAttribute("data-date")); + expect(dates.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d ?? ""))).toBe(true); + }); + + it("adds today class to cell matching today", () => { + const today = new Date(); + renderCalendar(today.getFullYear(), today.getMonth(), {}, {}); + const calendarEl = document.getElementById("calendar"); + const todayKey = + today.getFullYear() + + "-" + + String(today.getMonth() + 1).padStart(2, "0") + + "-" + + String(today.getDate()).padStart(2, "0"); + const todayCell = calendarEl?.querySelector('.day.today[data-date="' + todayKey + '"]'); + expect(todayCell).toBeTruthy(); + }); + + it("sets month title from state.lang and given year/month", () => { + state.lang = "en"; + renderCalendar(2025, 1, {}, {}); + const titleEl = document.getElementById("monthTitle"); + expect(titleEl?.textContent).toContain("2025"); + expect(titleEl?.textContent).toContain("February"); + }); +}); diff --git a/webapp/js/contactHtml.js b/webapp/js/contactHtml.js new file mode 100644 index 0000000..8594c7c --- /dev/null +++ b/webapp/js/contactHtml.js @@ -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(/' + + escapeHtml(p) + + ""; + if (showLabels) { + const label = t(lang, "contact.phone"); + parts.push( + '' + + escapeHtml(label) + + ": " + + linkHtml + + "" + ); + } 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 = + '' + + escapeHtml(display) + + ""; + if (showLabels) { + const label = t(lang, "contact.telegram"); + parts.push( + '' + + escapeHtml(label) + + ": " + + linkHtml + + "" + ); + } else { + parts.push(linkHtml); + } + } + } + + if (parts.length === 0) return ""; + const rowClass = classPrefix + "-row"; + return '
' + parts.join(separator) + "
"; +} diff --git a/webapp/js/contactHtml.test.js b/webapp/js/contactHtml.test.js new file mode 100644 index 0000000..40a3874 --- /dev/null +++ b/webapp/js/contactHtml.test.js @@ -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(" · "); + }); +}); diff --git a/webapp/js/currentDuty.js b/webapp/js/currentDuty.js index 27f774c..ee01291 100644 --- a/webapp/js/currentDuty.js +++ b/webapp/js/currentDuty.js @@ -3,9 +3,10 @@ * Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts. */ -import { currentDutyViewEl, state, loadingEl } from "./dom.js"; +import { getCurrentDutyViewEl, state, getLoadingEl } from "./dom.js"; import { t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; +import { buildContactLinksHtml } from "./contactHtml.js"; import { fetchDuties } from "./api.js"; import { localDateString, @@ -13,6 +14,11 @@ import { formatHHMM } from "./dateUtils.js"; +/** @type {(() => void)|null} Callback when user taps "Back to calendar". */ +let onBackCallback = null; +/** @type {(() => void)|null} Handler registered with Telegram BackButton.onClick. */ +let backButtonHandler = null; + /** * Find the duty that is currently active (start <= now < end). Prefer event_type === "duty". * @param {object[]} duties - List of duties with start_at, end_at, event_type @@ -30,54 +36,6 @@ export function findCurrentDuty(duties) { return null; } -/** - * Build contact HTML (phone + Telegram) for current duty card, styled like day-detail. - * @param {'ru'|'en'} lang - * @param {object} d - Duty with optional phone, username - * @returns {string} - */ -function buildContactHtml(lang, d) { - const parts = []; - if (d.phone && String(d.phone).trim()) { - const p = String(d.phone).trim(); - const label = t(lang, "contact.phone"); - const safeHref = - "tel:" + - p.replace(/&/g, "&").replace(/"/g, """).replace(/' + - escapeHtml(label) + - ": " + - '' + - escapeHtml(p) + - "" - ); - } - 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( - '' + - escapeHtml(label) + - ": " + - '' + - escapeHtml(display) + - "" - ); - } - } - return parts.length - ? '
' + parts.join(" ") + "
" - : ""; -} - /** * Render the current duty view content (card with duty or no-duty message). * @param {object|null} duty - Active duty or null @@ -120,7 +78,11 @@ export function renderCurrentDutyContent(duty, lang) { " " + endTime; const shiftLabel = t(lang, "current_duty.shift"); - const contactHtml = buildContactHtml(lang, duty); + const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, { + classPrefix: "current-duty-contact", + showLabels: true, + separator: " " + }); return ( '
' + @@ -149,16 +111,18 @@ export function renderCurrentDutyContent(duty, lang) { * @param {() => void} onBack - Callback when user taps "Back to calendar" */ export async function showCurrentDutyView(onBack) { + const currentDutyViewEl = getCurrentDutyViewEl(); const container = currentDutyViewEl && currentDutyViewEl.closest(".container"); const calendarSticky = document.getElementById("calendarSticky"); const dutyList = document.getElementById("dutyList"); if (!currentDutyViewEl) return; - currentDutyViewEl._onBack = onBack; + onBackCallback = onBack; currentDutyViewEl.classList.remove("hidden"); if (container) container.setAttribute("data-view", "currentDuty"); if (calendarSticky) calendarSticky.hidden = true; if (dutyList) dutyList.hidden = true; + const loadingEl = getLoadingEl(); if (loadingEl) loadingEl.classList.add("hidden"); const lang = state.lang; @@ -170,9 +134,9 @@ export async function showCurrentDutyView(onBack) { if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) { window.Telegram.WebApp.BackButton.show(); const handler = () => { - if (currentDutyViewEl._onBack) currentDutyViewEl._onBack(); + if (onBackCallback) onBackCallback(); }; - currentDutyViewEl._backButtonHandler = handler; + backButtonHandler = handler; window.Telegram.WebApp.BackButton.onClick(handler); } @@ -205,9 +169,7 @@ export async function showCurrentDutyView(onBack) { function handleCurrentDutyClick(e) { const btn = e.target && e.target.closest("[data-action='back']"); if (!btn) return; - if (currentDutyViewEl && currentDutyViewEl._onBack) { - currentDutyViewEl._onBack(); - } + if (onBackCallback) onBackCallback(); } /** @@ -215,24 +177,24 @@ function handleCurrentDutyClick(e) { * Hides Telegram BackButton and calls loadMonth so calendar is populated. */ export function hideCurrentDutyView() { + const currentDutyViewEl = getCurrentDutyViewEl(); const container = currentDutyViewEl && currentDutyViewEl.closest(".container"); const calendarSticky = document.getElementById("calendarSticky"); const dutyList = document.getElementById("dutyList"); - const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler; if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) { - if (backHandler) { - window.Telegram.WebApp.BackButton.offClick(backHandler); + if (backButtonHandler) { + window.Telegram.WebApp.BackButton.offClick(backButtonHandler); } window.Telegram.WebApp.BackButton.hide(); } if (currentDutyViewEl) { currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick); - currentDutyViewEl._onBack = null; - currentDutyViewEl._backButtonHandler = null; currentDutyViewEl.classList.add("hidden"); currentDutyViewEl.innerHTML = ""; } + onBackCallback = null; + backButtonHandler = null; if (container) container.removeAttribute("data-view"); if (calendarSticky) calendarSticky.hidden = false; if (dutyList) dutyList.hidden = false; diff --git a/webapp/js/dateUtils.test.js b/webapp/js/dateUtils.test.js index fde4c00..1ea7d4f 100644 --- a/webapp/js/dateUtils.test.js +++ b/webapp/js/dateUtils.test.js @@ -10,6 +10,10 @@ import { dutyOverlapsLocalRange, getMonday, formatHHMM, + firstDayOfMonth, + lastDayOfMonth, + formatDateKey, + dateKeyToDDMM, } from "./dateUtils.js"; describe("localDateString", () => { @@ -157,3 +161,70 @@ describe("formatHHMM", () => { expect(result).toMatch(/^\d{2}:\d{2}$/); }); }); + +describe("firstDayOfMonth", () => { + it("returns first day of month", () => { + const d = new Date(2025, 5, 15); + const result = firstDayOfMonth(d); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(5); + expect(result.getDate()).toBe(1); + }); + + it("handles January", () => { + const d = new Date(2025, 0, 31); + const result = firstDayOfMonth(d); + expect(result.getDate()).toBe(1); + expect(result.getMonth()).toBe(0); + }); +}); + +describe("lastDayOfMonth", () => { + it("returns last day of month", () => { + const d = new Date(2025, 0, 15); + const result = lastDayOfMonth(d); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(0); + expect(result.getDate()).toBe(31); + }); + + it("returns 28 for non-leap February", () => { + const d = new Date(2023, 1, 1); + const result = lastDayOfMonth(d); + expect(result.getDate()).toBe(28); + expect(result.getMonth()).toBe(1); + }); + + it("returns 29 for leap February", () => { + const d = new Date(2024, 1, 1); + const result = lastDayOfMonth(d); + expect(result.getDate()).toBe(29); + }); +}); + +describe("formatDateKey", () => { + it("formats ISO date string as DD.MM (local time)", () => { + const result = formatDateKey("2025-02-25T00:00:00Z"); + expect(result).toMatch(/^\d{2}\.\d{2}$/); + const [day, month] = result.split("."); + expect(Number(day)).toBeGreaterThanOrEqual(1); + expect(Number(day)).toBeLessThanOrEqual(31); + expect(Number(month)).toBeGreaterThanOrEqual(1); + expect(Number(month)).toBeLessThanOrEqual(12); + }); + + it("returns DD.MM format with zero-padding", () => { + const result = formatDateKey("2025-01-05T12:00:00Z"); + expect(result).toMatch(/^\d{2}\.\d{2}$/); + }); +}); + +describe("dateKeyToDDMM", () => { + it("converts YYYY-MM-DD to DD.MM", () => { + expect(dateKeyToDDMM("2025-02-25")).toBe("25.02"); + }); + + it("handles single-digit day and month", () => { + expect(dateKeyToDDMM("2025-01-09")).toBe("09.01"); + }); +}); diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js index 821c2f2..d0c61ec 100644 --- a/webapp/js/dayDetail.js +++ b/webapp/js/dayDetail.js @@ -2,9 +2,10 @@ * Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap. */ -import { calendarEl, state } from "./dom.js"; +import { getCalendarEl, state } from "./dom.js"; import { t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; +import { buildContactLinksHtml } from "./contactHtml.js"; import { localDateString, dateKeyToDDMM } from "./dateUtils.js"; import { getDutyMarkerRows } from "./hints.js"; @@ -35,43 +36,6 @@ function parseDataAttr(raw) { } } -/** - * Build HTML for contact info (phone link, Telegram username link) for a duty entry. - * @param {'ru'|'en'} lang - * @param {string|null|undefined} phone - * @param {string|null|undefined} username - Telegram username with or without leading @ - * @returns {string} - */ -function buildContactHtml(lang, phone, username) { - const parts = []; - if (phone && String(phone).trim()) { - const p = String(phone).trim(); - const label = t(lang, "contact.phone"); - const safeHref = "tel:" + p.replace(/&/g, "&").replace(/"/g, """).replace(/' + - escapeHtml(label) + ": " + - '' + - escapeHtml(p) + "" - ); - } - 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( - '' + - escapeHtml(label) + ": " + - '' + - escapeHtml(display) + "" - ); - } - } - return parts.length ? '
' + parts.join(" ") + "
" : ""; -} - /** * Build HTML content for the day detail panel. * @param {string} dateKey - YYYY-MM-DD @@ -127,7 +91,11 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) { const phone = r.phone != null ? r.phone : (duty && duty.phone); const username = r.username != null ? r.username : (duty && duty.username); const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : ""; - const contactHtml = buildContactHtml(lang, phone, username); + const contactHtml = buildContactLinksHtml(lang, phone, username, { + classPrefix: "day-detail-contact", + showLabels: true, + separator: " " + }); html += "
  • " + (timeHtml ? '' + timeHtml + "" : "") + @@ -199,6 +167,7 @@ function positionPopover(panel, cellRect) { const panelRect = panel.getBoundingClientRect(); let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2; let top = cellRect.bottom + 8; + /* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */ if (top + panelRect.height > vh - margin) { top = cellRect.top - panelRect.height - 8; panel.classList.add("day-detail-panel--below"); @@ -256,6 +225,7 @@ function showAsPopover(cellRect) { const target = e.target instanceof Node ? e.target : null; if (!target || !panelEl) return; if (panelEl.contains(target)) return; + const calendarEl = getCalendarEl(); if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return; hideDayDetail(); }; @@ -390,6 +360,7 @@ function ensurePanelInDom() { * Bind delegated click/keydown on calendar for .day cells. */ export function initDayDetail() { + const calendarEl = getCalendarEl(); if (!calendarEl) return; calendarEl.addEventListener("click", (e) => { const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null); diff --git a/webapp/js/dom.js b/webapp/js/dom.js index 4ca66ed..6970843 100644 --- a/webapp/js/dom.js +++ b/webapp/js/dom.js @@ -1,39 +1,62 @@ /** * DOM references and shared application state. + * Element refs are resolved lazily via getters so modules can be imported before DOM is ready. */ -/** @type {HTMLDivElement|null} */ -export const calendarEl = document.getElementById("calendar"); +/** @returns {HTMLDivElement|null} */ +export function getCalendarEl() { + return document.getElementById("calendar"); +} -/** @type {HTMLElement|null} */ -export const monthTitleEl = document.getElementById("monthTitle"); +/** @returns {HTMLElement|null} */ +export function getMonthTitleEl() { + return document.getElementById("monthTitle"); +} -/** @type {HTMLDivElement|null} */ -export const dutyListEl = document.getElementById("dutyList"); +/** @returns {HTMLDivElement|null} */ +export function getDutyListEl() { + return document.getElementById("dutyList"); +} -/** @type {HTMLElement|null} */ -export const loadingEl = document.getElementById("loading"); +/** @returns {HTMLElement|null} */ +export function getLoadingEl() { + return document.getElementById("loading"); +} -/** @type {HTMLElement|null} */ -export const errorEl = document.getElementById("error"); +/** @returns {HTMLElement|null} */ +export function getErrorEl() { + return document.getElementById("error"); +} -/** @type {HTMLElement|null} */ -export const accessDeniedEl = document.getElementById("accessDenied"); +/** @returns {HTMLElement|null} */ +export function getAccessDeniedEl() { + return document.getElementById("accessDenied"); +} -/** @type {HTMLElement|null} */ -export const headerEl = document.querySelector(".header"); +/** @returns {HTMLElement|null} */ +export function getHeaderEl() { + return document.querySelector(".header"); +} -/** @type {HTMLElement|null} */ -export const weekdaysEl = document.querySelector(".weekdays"); +/** @returns {HTMLElement|null} */ +export function getWeekdaysEl() { + return document.querySelector(".weekdays"); +} -/** @type {HTMLButtonElement|null} */ -export const prevBtn = document.getElementById("prevMonth"); +/** @returns {HTMLButtonElement|null} */ +export function getPrevBtn() { + return document.getElementById("prevMonth"); +} -/** @type {HTMLButtonElement|null} */ -export const nextBtn = document.getElementById("nextMonth"); +/** @returns {HTMLButtonElement|null} */ +export function getNextBtn() { + return document.getElementById("nextMonth"); +} -/** @type {HTMLDivElement|null} */ -export const currentDutyViewEl = document.getElementById("currentDutyView"); +/** @returns {HTMLDivElement|null} */ +export function getCurrentDutyViewEl() { + return document.getElementById("currentDutyView"); +} /** Currently viewed month (mutable). */ export const state = { diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js index a43c69c..bab6e71 100644 --- a/webapp/js/dutyList.js +++ b/webapp/js/dutyList.js @@ -2,9 +2,10 @@ * Duty list (timeline) rendering. */ -import { dutyListEl, state } from "./dom.js"; +import { getDutyListEl, state } from "./dom.js"; import { t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; +import { buildContactLinksHtml } from "./contactHtml.js"; import { localDateString, firstDayOfMonth, @@ -14,37 +15,6 @@ import { formatDateKey } from "./dateUtils.js"; -/** - * Build HTML for contact links (phone, Telegram) for a duty. Returns empty string if none. - * @param {'ru'|'en'} lang - * @param {object} d - Duty with optional phone, username - * @returns {string} - */ -function dutyCardContactHtml(lang, d) { - const parts = []; - if (d.phone && String(d.phone).trim()) { - const p = String(d.phone).trim(); - const safeHref = "tel:" + p.replace(/&/g, "&").replace(/"/g, """).replace(/' + - escapeHtml(p) + "" - ); - } - 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( - '@' + - escapeHtml(u) + "" - ); - } - } - return parts.length - ? '
    ' + parts.join(" · ") + "
    " - : ""; -} - /** Phone icon SVG for flip button (show contacts). */ const ICON_PHONE = ''; @@ -79,7 +49,11 @@ export function dutyTimelineCardHtml(d, isCurrent) { ? t(lang, "duty.now_on_duty") : (t(lang, "event_type." + (d.event_type || "duty"))); const extraClass = isCurrent ? " duty-item--current" : ""; - const contactHtml = dutyCardContactHtml(lang, d); + const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, { + classPrefix: "duty-contact", + showLabels: false, + separator: " · " + }); const hasContacts = Boolean( (d.phone && String(d.phone).trim()) || (d.username && String(d.username).trim()) @@ -174,12 +148,12 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) { ' ' + escapeHtml(d.full_name) + '
    ' + - timeOrRange + + escapeHtml(timeOrRange) + "
  • " ); } -/** Whether the delegated flip-button click listener has been attached to dutyListEl. */ +/** Whether the delegated flip-button click listener has been attached to duty list element. */ let flipListenerAttached = false; /** @@ -187,6 +161,7 @@ let flipListenerAttached = false; * @param {object[]} duties - Duties (only duty type used for timeline) */ export function renderDutyList(duties) { + const dutyListEl = getDutyListEl(); if (!dutyListEl) return; if (!flipListenerAttached) { @@ -277,8 +252,9 @@ export function renderDutyList(duties) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } }; - const currentDutyCard = dutyListEl.querySelector(".duty-item--current"); - const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today"); + const listEl = getDutyListEl(); + const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null; + const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null; if (currentDutyCard) { scrollToEl(currentDutyCard); } else if (todayBlock) { diff --git a/webapp/js/dutyList.test.js b/webapp/js/dutyList.test.js index 2ee5ba5..424e8d8 100644 --- a/webapp/js/dutyList.test.js +++ b/webapp/js/dutyList.test.js @@ -1,9 +1,10 @@ /** - * Unit tests for dutyList (dutyTimelineCardHtml, contact rendering). + * Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering). */ -import { describe, it, expect, beforeAll } from "vitest"; -import { dutyTimelineCardHtml } from "./dutyList.js"; +import { describe, it, expect, beforeAll, vi, afterEach } from "vitest"; +import * as dateUtils from "./dateUtils.js"; +import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js"; describe("dutyList", () => { beforeAll(() => { @@ -89,4 +90,75 @@ describe("dutyList", () => { expect(html).not.toContain("duty-contact-row"); }); }); + + describe("dutyItemHtml", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("escapes timeOrRange so HTML special chars are not rendered raw", () => { + vi.spyOn(dateUtils, "formatHHMM").mockReturnValue("12:00 & 13:00"); + vi.spyOn(dateUtils, "formatDateKey").mockReturnValue("01.02.2025"); + const d = { + event_type: "duty", + full_name: "Test", + start_at: "2025-03-01T12:00:00", + end_at: "2025-03-01T13:00:00", + }; + const html = dutyItemHtml(d, null, false); + expect(html).toContain("&"); + expect(html).not.toContain('
    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"); + }); + }); }); diff --git a/webapp/js/hints.js b/webapp/js/hints.js index 7280ad3..5814b0d 100644 --- a/webapp/js/hints.js +++ b/webapp/js/hints.js @@ -2,7 +2,7 @@ * Tooltips for calendar info buttons and duty markers. */ -import { calendarEl, state } from "./dom.js"; +import { getCalendarEl, state } from "./dom.js"; import { t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; import { localDateString, formatHHMM } from "./dateUtils.js"; @@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) { * Remove active class from all duty/unavailable/vacation markers. */ export function clearActiveDutyMarker() { + const calendarEl = getCalendarEl(); if (!calendarEl) return; calendarEl .querySelectorAll( @@ -261,6 +262,25 @@ export function clearActiveDutyMarker() { /** Timeout for hiding duty marker hint on mouseleave (delegated). */ let dutyMarkerHideTimeout = null; +const HINT_FADE_MS = 150; + +/** + * Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active. + * @param {HTMLElement} hintEl - The hint element to dismiss + * @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide + * @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout) + */ +export function dismissHint(hintEl, opts = {}) { + hintEl.classList.remove("calendar-event-hint--visible"); + const id = setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + if (opts.clearActive) clearActiveDutyMarker(); + if (typeof opts.afterHide === "function") opts.afterHide(); + }, HINT_FADE_MS); + return id; +} + const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker"; /** @@ -304,6 +324,7 @@ function getOrCreateDutyMarkerHint() { export function initHints() { const calendarEventHint = getOrCreateCalendarEventHint(); const dutyMarkerHint = getOrCreateDutyMarkerHint(); + const calendarEl = getCalendarEl(); if (!calendarEl) return; calendarEl.addEventListener("click", (e) => { @@ -317,11 +338,7 @@ export function initHints() { positionHint(calendarEventHint, btn.getBoundingClientRect()); calendarEventHint.dataset.active = "1"; } else { - calendarEventHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - calendarEventHint.hidden = true; - calendarEventHint.removeAttribute("data-active"); - }, 150); + dismissHint(calendarEventHint); } return; } @@ -330,11 +347,7 @@ export function initHints() { if (marker) { e.stopPropagation(); if (marker.classList.contains("calendar-marker-active")) { - dutyMarkerHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - dutyMarkerHint.hidden = true; - dutyMarkerHint.removeAttribute("data-active"); - }, 150); + dismissHint(dutyMarkerHint); marker.classList.remove("calendar-marker-active"); return; } @@ -377,31 +390,23 @@ export function initHints() { const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null; if (toMarker) return; if (dutyMarkerHint.dataset.active) return; - dutyMarkerHint.classList.remove("calendar-event-hint--visible"); - dutyMarkerHideTimeout = setTimeout(() => { - dutyMarkerHint.hidden = true; - dutyMarkerHideTimeout = null; - }, 150); + dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, { + afterHide: () => { + dutyMarkerHideTimeout = null; + }, + }); }); if (!state.calendarHintBound) { state.calendarHintBound = true; document.addEventListener("click", () => { if (calendarEventHint.dataset.active) { - calendarEventHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - calendarEventHint.hidden = true; - calendarEventHint.removeAttribute("data-active"); - }, 150); + dismissHint(calendarEventHint); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && calendarEventHint.dataset.active) { - calendarEventHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - calendarEventHint.hidden = true; - calendarEventHint.removeAttribute("data-active"); - }, 150); + dismissHint(calendarEventHint); } }); } @@ -410,22 +415,12 @@ export function initHints() { state.dutyMarkerHintBound = true; document.addEventListener("click", () => { if (dutyMarkerHint.dataset.active) { - dutyMarkerHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - dutyMarkerHint.hidden = true; - dutyMarkerHint.removeAttribute("data-active"); - clearActiveDutyMarker(); - }, 150); + dismissHint(dutyMarkerHint, { clearActive: true }); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && dutyMarkerHint.dataset.active) { - dutyMarkerHint.classList.remove("calendar-event-hint--visible"); - setTimeout(() => { - dutyMarkerHint.hidden = true; - dutyMarkerHint.removeAttribute("data-active"); - clearActiveDutyMarker(); - }, 150); + dismissHint(dutyMarkerHint, { clearActive: true }); } }); } diff --git a/webapp/js/hints.test.js b/webapp/js/hints.test.js index e09ad0f..20ad3a8 100644 --- a/webapp/js/hints.test.js +++ b/webapp/js/hints.test.js @@ -1,10 +1,11 @@ /** * Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic. * Covers: sorting order preservation, idx=0 with total>1 and startSameDay. + * Also tests dismissHint helper. */ -import { describe, it, expect, beforeAll } from "vitest"; -import { getDutyMarkerRows } from "./hints.js"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; +import { getDutyMarkerRows, dismissHint } from "./hints.js"; const FROM = "from"; const TO = "until"; @@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => { expect(rows[2].timePrefix).toContain("15:00"); }); }); + +describe("dismissHint", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("removes visible class immediately and hides element after delay", () => { + const el = document.createElement("div"); + el.classList.add("calendar-event-hint--visible"); + el.hidden = false; + el.setAttribute("data-active", "1"); + + dismissHint(el); + + expect(el.classList.contains("calendar-event-hint--visible")).toBe(false); + expect(el.hidden).toBe(false); + + vi.advanceTimersByTime(150); + + expect(el.hidden).toBe(true); + expect(el.hasAttribute("data-active")).toBe(false); + }); + + it("returns timeout id usable with clearTimeout", () => { + const el = document.createElement("div"); + const id = dismissHint(el); + expect(id).toBeDefined(); + clearTimeout(id); + vi.advanceTimersByTime(150); + expect(el.hidden).toBe(false); + }); + + it("calls afterHide callback after delay when provided", () => { + const el = document.createElement("div"); + let called = false; + dismissHint(el, { + afterHide: () => { + called = true; + }, + }); + expect(called).toBe(false); + vi.advanceTimersByTime(150); + expect(called).toBe(true); + }); +}); diff --git a/webapp/js/main.js b/webapp/js/main.js index 03c7f54..d7c068a 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -8,12 +8,12 @@ import { getInitData, isLocalhost } from "./auth.js"; import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; import { state, - accessDeniedEl, - prevBtn, - nextBtn, - loadingEl, - errorEl, - weekdaysEl + getAccessDeniedEl, + getPrevBtn, + getNextBtn, + getLoadingEl, + getErrorEl, + getWeekdaysEl } from "./dom.js"; import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js"; import { fetchDuties, fetchCalendarEvents } from "./api.js"; @@ -39,15 +39,19 @@ initTheme(); state.lang = getLang(); document.documentElement.lang = state.lang; document.title = t(state.lang, "app.title"); +const loadingEl = getLoadingEl(); const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); const dayLabels = weekdayLabels(state.lang); +const weekdaysEl = getWeekdaysEl(); if (weekdaysEl) { const spans = weekdaysEl.querySelectorAll("span"); spans.forEach((span, i) => { if (dayLabels[i]) span.textContent = dayLabels[i]; }); } +const prevBtn = getPrevBtn(); +const nextBtn = getNextBtn(); if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month")); if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); @@ -99,12 +103,14 @@ function requireTelegramOrLocalhost(onAllowed) { return; } showAccessDenied(undefined); - if (loadingEl) loadingEl.classList.add("hidden"); + const loading = getLoadingEl(); + if (loading) loading.classList.add("hidden"); }, RETRY_DELAY_MS); return; } showAccessDenied(undefined); - if (loadingEl) loadingEl.classList.add("hidden"); + const loading = getLoadingEl(); + if (loading) loading.classList.add("hidden"); } /** AbortController for the in-flight loadMonth request; aborted when a new load starts. */ @@ -121,7 +127,9 @@ async function loadMonth() { hideAccessDenied(); setNavEnabled(false); + const loadingEl = getLoadingEl(); if (loadingEl) loadingEl.classList.remove("hidden"); + const errorEl = getErrorEl(); if (errorEl) errorEl.hidden = true; const current = state.current; const first = firstDayOfMonth(current); @@ -185,21 +193,26 @@ async function loadMonth() { setNavEnabled(true); return; } - if (loadingEl) loadingEl.classList.add("hidden"); + const loading = getLoadingEl(); + if (loading) loading.classList.add("hidden"); setNavEnabled(true); } -if (prevBtn) { - prevBtn.addEventListener("click", () => { +const prevBtnEl = getPrevBtn(); +if (prevBtnEl) { + prevBtnEl.addEventListener("click", () => { if (document.body.classList.contains("day-detail-sheet-open")) return; + const accessDeniedEl = getAccessDeniedEl(); if (accessDeniedEl && !accessDeniedEl.hidden) return; state.current.setMonth(state.current.getMonth() - 1); loadMonth(); }); } -if (nextBtn) { - nextBtn.addEventListener("click", () => { +const nextBtnEl = getNextBtn(); +if (nextBtnEl) { + nextBtnEl.addEventListener("click", () => { if (document.body.classList.contains("day-detail-sheet-open")) return; + const accessDeniedEl = getAccessDeniedEl(); if (accessDeniedEl && !accessDeniedEl.hidden) return; state.current.setMonth(state.current.getMonth() + 1); loadMonth(); @@ -227,12 +240,15 @@ if (nextBtn) { (e) => { if (e.changedTouches.length === 0) return; if (document.body.classList.contains("day-detail-sheet-open")) return; + const accessDeniedEl = getAccessDeniedEl(); if (accessDeniedEl && !accessDeniedEl.hidden) return; const touch = e.changedTouches[0]; const deltaX = touch.clientX - startX; const deltaY = touch.clientY - startY; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return; + const prevBtn = getPrevBtn(); + const nextBtn = getNextBtn(); if (deltaX > SWIPE_THRESHOLD) { if (prevBtn && prevBtn.disabled) return; state.current.setMonth(state.current.getMonth() - 1); diff --git a/webapp/js/theme.test.js b/webapp/js/theme.test.js new file mode 100644 index 0000000..d57f9a6 --- /dev/null +++ b/webapp/js/theme.test.js @@ -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)); + }); + }); +}); diff --git a/webapp/js/ui.js b/webapp/js/ui.js index e735dcf..9520f25 100644 --- a/webapp/js/ui.js +++ b/webapp/js/ui.js @@ -4,15 +4,15 @@ import { state, - calendarEl, - dutyListEl, - loadingEl, - errorEl, - accessDeniedEl, - headerEl, - weekdaysEl, - prevBtn, - nextBtn + getCalendarEl, + getDutyListEl, + getLoadingEl, + getErrorEl, + getAccessDeniedEl, + getHeaderEl, + getWeekdaysEl, + getPrevBtn, + getNextBtn } from "./dom.js"; import { t } from "./i18n.js"; @@ -21,6 +21,13 @@ import { t } from "./i18n.js"; * @param {string} [serverDetail] - message from API 403 detail (shown below main text when present) */ export function showAccessDenied(serverDetail) { + const headerEl = getHeaderEl(); + const weekdaysEl = getWeekdaysEl(); + const calendarEl = getCalendarEl(); + const dutyListEl = getDutyListEl(); + const loadingEl = getLoadingEl(); + const errorEl = getErrorEl(); + const accessDeniedEl = getAccessDeniedEl(); if (headerEl) headerEl.hidden = true; if (weekdaysEl) weekdaysEl.hidden = true; if (calendarEl) calendarEl.hidden = true; @@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) { * Hide access-denied and show calendar/list/header/weekdays. */ export function hideAccessDenied() { + const accessDeniedEl = getAccessDeniedEl(); + const headerEl = getHeaderEl(); + const weekdaysEl = getWeekdaysEl(); + const calendarEl = getCalendarEl(); + const dutyListEl = getDutyListEl(); if (accessDeniedEl) accessDeniedEl.hidden = true; if (headerEl) headerEl.hidden = false; if (weekdaysEl) weekdaysEl.hidden = false; @@ -56,6 +68,8 @@ export function hideAccessDenied() { * @param {string} msg - Error text */ export function showError(msg) { + const errorEl = getErrorEl(); + const loadingEl = getLoadingEl(); if (errorEl) { errorEl.textContent = msg; errorEl.hidden = false; @@ -68,6 +82,8 @@ export function showError(msg) { * @param {boolean} enabled */ export function setNavEnabled(enabled) { + const prevBtn = getPrevBtn(); + const nextBtn = getNextBtn(); if (prevBtn) prevBtn.disabled = !enabled; if (nextBtn) nextBtn.disabled = !enabled; } diff --git a/webapp/js/ui.test.js b/webapp/js/ui.test.js new file mode 100644 index 0000000..8cb0a1c --- /dev/null +++ b/webapp/js/ui.test.js @@ -0,0 +1,122 @@ +/** + * Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled. + */ + +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; + +beforeAll(() => { + document.body.innerHTML = + '

    ' + + '
    ' + + '
    ' + + ''; +}); + +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); + }); + }); +}); diff --git a/webapp/style.css b/webapp/style.css deleted file mode 100644 index f64a6d5..0000000 --- a/webapp/style.css +++ /dev/null @@ -1,1124 +0,0 @@ -/* === 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; -} - -.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 grid & day cells */ -.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)); -} - -/* === 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; -} - -/* === 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; -} - -/* === 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); -} - -/* === 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; -} - -.duty-flip-inner { - transition: transform 0.4s; - transform-style: preserve-3d; - position: relative; - min-height: 0; -} - -.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)); -} - -/* === Loading / error / access denied */ -.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; -}