Fix API integration: proxy via Angular, date format, root redirect
CI / ci (push) Failing after 36s
Deploy / build-and-deploy (push) Failing after 5s

- Point API_BASE_URL to localhost:4200 (Angular's dev proxy) which
  correctly forwards to flights.test.aeroflot.ru with proper headers
- Convert URL date format (yyyyMMdd) to API format (yyyy-MM-ddT00:00:00)
  matching Angular's ApiFormatterService behavior
- Add standalone api-proxy.mjs script for running without Angular
- Root page redirect uses both loader and client-side navigate
- SignalR hub URL points to platform.yc.webzavod.ru/tracker/hub
- Remove broken server/modern-js.server.ts (proxy handled externally)
This commit is contained in:
2026-04-15 22:08:54 +03:00
parent 5fc67f81bd
commit e7c20c3d2d
16 changed files with 2478 additions and 29 deletions
+263
View File
@@ -0,0 +1,263 @@
# Пользовательские истории: Индекс и обзор
## ✅ ВЕРИФИЦИРОВАНО: Только реально реализованные функции Angular
**Дата последнего обновления:** 9 апреля 2026 GMT+3
**Статус:** Очищено - удалены все нереализованные/гипотетические функции
**Источник верификации:** `/Users/gnezim/_projects/tims/flights_web/Aeroflot.Flights.Web/ClientApp` (Angular 12.2.13)
**Итоги аудита (Версия 4.0):**
- 175 → **85 историй** (удалено 90, или ~51%)
- 14 → **7 документов** (удалено 4 документа полностью + 1 устаревший индекс)
- 100% оставшихся историй имеют подтверждение в исходном коде Angular
---
## 📌 Изменения в версии 4.0
### Удалены документы (нереализованные функции)
-**Документ 8: Многолётные рейсы** (US-106-120) — кроме базового отображения, всё гипотетика (трансфер услуги, переадресация багажа, риски пересадок). Базовый показ stay в Doc 4 (US-42).
-**Документ 10: Производительность** (US-136-150) — Service Worker, A/B тесты, Web Workers, виртуализация — ничего нет в коде. Только базовый CacheService 30s.
-**Документ 11: Продвинутые функции** (US-151-165) — конвертация валют, RTL, лояльность, IATA-поиск — не реализовано. Многоязычность уже описана в Doc 1 US-2.
-**Документ 12: Граничные случаи целостности** (US-166-175) — кросс-таб синхронизация, восстановление сессии, rollback — ничего нет.
### Удалённые истории внутри сохранённых документов
- **Doc 2:** US-21 (обработка ошибок сети) — нет UI обработки ошибок API
- **Doc 3:** US-34 (кнопка "Очистить") — нет такой кнопки в форме
- **Doc 4:** US-40, US-41 (перенесены в Doc 5), US-43 (фильтр по авиакомпании), US-44 (кнопка "Купить билет"), US-45 (живые обновления статусов в расписании)
- **Doc 5:** US-57 (ограничения полёта), US-58 (рецензии), US-59 (исторические данные), US-60 (сравнение рейсов), US-61 (Print version)
- **Doc 6:** US-77 (кластеризация), US-78 (выделение результатов), US-80 (топ маршруты), US-81 (легенда), US-82 (центральный офис), US-83 (ночной режим), US-84 (мобильная карта)
- **Doc 7:** US-87 (Service Worker offline), US-97 (WCAG проверка контраста), US-105 (session timeout)
### Удалён устаревший файл
-`USER_STORIES_VERIFIED_INDEX.md` — содержал устаревшие данные о 175 историях
---
## Обзор проекта
Это набор пользовательских историй для **UI Dashboard** — React-версии онлайн доски полётов Аэрофлота. Документы организованы по функциональным областям и содержат описание целей, путей клиента и критериев приёмки.
**Всего историй:** 85 пользовательских историй ✅
**Документов:** 7
**Источник:** Angular 12.2.13 (Aeroflot.Flights.Web/ClientApp)
**Языки:** 9 (ru-ru, en-us, zh-cn, ko-kr, ja-jp, de-de, fr-fr, es-es, it-it)
---
## Структура документов
### 📄 [Документ 1: Навигация и элементы интерфейса](user-stories-1-navigation-ui.md)
**US-1 до US-11** (11 историй)
- US-1: Переключение между вкладками (Онлайн-Табло, Расписание, Карта полётов)
- US-2: Переключение языка интерфейса (9 языков)
- US-3: Хлебные крошки
- US-4: Кнопка обратной связи
- US-5: Кнопка прокрутки к началу
- US-6: Боковая панель с фильтрами
- US-7: Популярные запросы (popular-requests)
- US-8: История поиска (in-memory)
- US-9: Локализация заголовков и мета-тегов
- US-10: Адаптивный дизайн
- US-11: Отсутствие ошибок в консоли
---
### 📄 [Документ 2: Онлайн доска полётов](user-stories-2-online-board.md)
**US-12 до US-22 (без US-21)** — 10 историй
- US-12: Поиск по номеру рейса (SU 1402)
- US-13: Выбор даты для поиска
- US-14: Поиск по городу отправления (с автодополнением)
- US-15: Поиск полётов с прибытием в город
- US-16: Поиск маршрута (А → Б)
- US-17: День вылета (переключение)
- US-18: Фильтр по времени вылета/прибытия
- US-19: Расширенный просмотр рейса
- US-20: Обработка пустых результатов
- US-22: Индикатор загрузки
---
### 📄 [Документ 3: Поиск расписания полётов](user-stories-3-schedule-search.md)
**US-23 до US-33** — 11 историй
- US-23: Переход на вкладку "Расписание"
- US-24: Ввод города отправления
- US-25: Ввод города прибытия
- US-26: Кнопка "Обменять"
- US-27: Выбор недели вылета
- US-28: Поиск туров (туда и обратно)
- US-29: Фильтр "Только прямые рейсы"
- US-30: Фильтр по времени вылета
- US-31: Фильтр по времени прибытия
- US-32: Проверка валидности параметров
- US-33: URL параметры расписания
---
### 📄 [Документ 4: Результаты расписания](user-stories-4-schedule-results.md)
**US-35 до US-46 (без US-43, 44, 45; US-40, 41 в Doc 5)** — 7 историй
- US-35: Страница результатов расписания
- US-36: Переключение между днями
- US-37: Навигация между неделями (week-tabs)
- US-38: Расширенный просмотр полёта
- US-39: Сортировка результатов (6 режимов)
- US-42: Многоугольные полёты (рейсы с пересадками)
- US-46: Кнопка "Назад" на странице деталей
---
### 📄 [Документ 5: Детали полёта](user-stories-5-flight-details.md)
**US-40, US-41, US-47 до US-56, US-62, US-63, US-64** — 15 историй
- US-40: Услуги на борту
- US-41: Расписание полёта и время
- US-47: Страница деталей полёта
- US-48: Основная информация о полёте
- US-49: Статус и детали статуса
- US-50: Информация о воздушном судне
- US-51: Информация об авиакомпании (operator-logo)
- US-52: Информация об аэропортах (station)
- US-53: Дни работы рейса
- US-54: Действия с полётом (купить, регистрация, печать, поделиться)
- US-55: Схема маршрута / timeline
- US-56: Информация о пересадках
- US-62: Поделиться информацией
- US-63: Отсутствие ошибок на странице
- US-64: Целостность данных
---
### 📄 [Документ 6: Карта полётов](user-stories-6-flights-map.md)
**US-65 до US-79 (без US-77, 78)** — 13 историй
- US-65: Переход на вкладку "Карта полётов"
- US-66: Отображение маршрутов на карте (Leaflet polyline)
- US-67: Выбор города отправления (city-autocomplete)
- US-68: Выбор города прибытия
- US-69: Обмен городов
- US-70: Выбор даты
- US-71: Фильтр "Внутренние рейсы" (toggle-switch)
- US-72: Фильтр "Международные рейсы"
- US-73: Фильтр "Соединительные рейсы"
- US-74: Зуммирование (Leaflet defaults, zoom 3-6)
- US-75: Панорамирование (Leaflet defaults, maxBounds)
- US-76: Popup при выборе маршрута
- US-79: Ссылка "Купить билет" (внешняя)
---
### 📄 [Документ 7: Обработка ошибок и доступность](user-stories-7-errors-accessibility.md)
**US-85 до US-104 (без US-87, 97)** — 18 историй
**Ошибки:**
- US-85: Ошибка 404
- US-86: Ошибка 500
- US-88: Timeout при загрузке
- US-89: Некорректный ввод
- US-90: Невалидная комбинация параметров
**Граничные случаи:**
- US-91: Пустой результат поиска
- US-92: Unicode символы
- US-93: Очень длинные имена
- US-94: Быстрая последовательность поисков
**Доступность:**
- US-95: Клавиатурная навигация (PrimeNG defaults)
- US-96: ARIA-метки
- US-98: Focus visible
- US-99: Масштабирование текста
- US-100: Сенсорная навигация
**Состояние и кэш:**
- US-101: Персистентное состояние (StateService)
- US-102: История браузера
- US-103: Обработка больших объёмов
- US-104: Кэширование (CacheService, 30s)
---
## Статистика
| Документ | Диапазон | Историй | Статус |
| ------------------------ | ----------------------- | ------- | --------------------------- |
| 1. Навигация | US-1 до US-11 | 11 | ✅ Все реализованы |
| 2. Онлайн-Табло | US-12 до US-22 | 10 | ✅ Все реализованы |
| 3. Поиск расписания | US-23 до US-33 | 11 | ✅ Все реализованы |
| 4. Результаты расписания | US-35-39, 42, 46 | 7 | ✅ Все реализованы |
| 5. Детали полёта | US-40, 41, 47-56, 62-64 | 15 | ✅ Все реализованы |
| 6. Карта полётов | US-65-76, 79 | 13 | ✅ Все реализованы |
| 7. Ошибки и доступность | US-85-86, 88-96, 98-104 | 18 | ✅ Все реализованы |
| **ИТОГО** | **85 историй** | **85** | ✅ Полностью верифицировано |
---
## Удалённые документы и истории
### Полностью удалённые документы
| Документ | Историй | Причина |
| ------------------------------------ | ------- | ----------------------------------------------- |
| 8. Многолётные рейсы (US-106-120) | 15 | Только базовый показ работает (см. Doc 4 US-42) |
| 10. Производительность (US-136-150) | 15 | Service Worker, A/B, Web Workers — нет в коде |
| 11. Продвинутые функции (US-151-165) | 15 | Валюта, лояльность, RTL, IATA-поиск — нет |
| 12. Граничные случаи (US-166-175) | 10 | Кросс-таб синхронизация, recovery — нет |
| **Всего удалено** | **55** | |
### Удалённые истории внутри сохранённых документов
| Doc | Удалено | Причина |
| --------- | -------------- | ------------------------------------------------------------- |
| 2 | US-21 | Нет UI обработки сетевых ошибок |
| 3 | US-34 | Нет кнопки "Очистить" в форме |
| 4 | US-43,44,45 | Нет фильтра по авиакомпании, кнопки "Купить", live-обновлений |
| 5 | US-57-61 | Нет ограничений, рецензий, истории, сравнения, print-версии |
| 6 | US-77,78,80-84 | Нет кластеризации, выделения, легенды, ночного режима, моб. |
| 7 | US-87,97,105 | Нет Service Worker, WCAG-проверки контраста, session timeout |
| **Всего** | **35** | |
---
## Источник верификации
Каждая оставшаяся история подтверждена в исходном коде по следующим путям:
- `src/app/features/online-board/` — Doc 2
- `src/app/features/schedule/` — Docs 3, 4
- `src/app/features/flights-map/` — Doc 6
- `src/app/features/popular-requests/` — Doc 1 US-7
- `src/app/modules/pages/details/` — Doc 5
- `src/app/components/` (city-autocomplete, dates-selectors, breadcrumds, search-history, page) — Doc 1, общие
- `src/app/shared/services/` (localization, state, cache) — Doc 1, Doc 7
- `src/app/modules/pages/error-pages/` — Doc 7
---
## Управление версиями
| Версия | Дата | Историй | Документов | Примечания |
| ------- | -------------- | ------- | ---------- | ----------------------------------------------------- |
| 1.0 | Feb 2026 | 103 | 7 | Начальная версия |
| 1.4 | Mar 2026 | 103 | 7 | Исправления, восстановлены US-40, US-41 |
| 2.0 | Apr 2026 | 210 | 14 | Расширение (многолётные, история, производительность) |
| 3.0 | Apr 2026 | 175 | 11 | Удалены удалённые: история, уведомления, аккаунты |
| **4.0** | **Apr 9 2026** | **85** | **7** | **Кодовая верификация: удалено всё неподтверждённое** |
+255
View File
@@ -0,0 +1,255 @@
# Пользовательские истории: Навигация и элементы интерфейса
## US-1: Навигация по основным вкладкам
**Цель:** Пользователь может переключаться между основными разделами приложения (Онлайн-Табло, Расписание, Карта полётов) для поиска информации о рейсах в разных режимах.
**Путь клиента:**
1. **Открытие приложения** — пользователь заходит на сайт, срабатывает редирект с корневого пути на `/onlineboard`, в верхней части страницы отображается компонент `page-tabs` с двумя вкладками: «Онлайн-Табло» и «Расписание».
2. **Отображение третьей вкладки** — если включён feature flag `flightsMap`, дополнительно появляется вкладка «Карта полётов» во втором ряду; без флага она не рендерится.
3. **Индикация активной вкладки** — текущая вкладка помечена CSS-классом `active`; при наведении показывается tooltip с текстом из ключей `SHARED.TAB-BOARD-TOOLTIP`, `SHARED.TAB-SCHEDULE-TOOLTIP`, `SHARED.TAB-FLIGHTS-MAP-TOOLTIP`.
4. **Клик по вкладке «Расписание»** — Angular Router выполняет переход на `/schedule`, URL обновляется, загружается `ScheduleModule`, активный класс переходит на новую вкладку.
5. **Клик по вкладке «Карта полётов»** — роутер через `FeatureFlagGuard` проверяет флаг и, если он включён, загружает `FlightsMapModule` по пути `/flights-map`.
6. **Возврат на Онлайн-Табло** — пользователь кликает первую вкладку, URL меняется на `/onlineboard`, загружается `OnlineBoardModule`, активная вкладка снова подсвечивается.
**Критерии приёмки:**
- ✅ На старте пользователь попадает на `/onlineboard` (редирект с `/`).
- ✅ Видны ровно две вкладки, если `flightsMap` выключен, и три — если включён.
- ✅ Клик по вкладке меняет URL на `/onlineboard`, `/schedule` или `/flights-map` без перезагрузки страницы.
- ✅ Активная вкладка визуально отличается от неактивных (класс `active`).
- ✅ При наведении появляется tooltip с локализованным текстом.
- ✅ Переход между вкладками не вызывает ошибок в консоли.
**Примечание:** Видимость вкладки «Карта полётов» управляется feature flag `flightsMap` (`FeatureFlagGuard` + `*ngIf="flightsMapEnabled"` в `page-tabs.component.html`).
---
## US-2: Переключение языка интерфейса
**Цель:** Пользователь может использовать приложение на одном из девяти поддерживаемых языков.
**Путь клиента:**
1. **Определение языка**`LocalizationService` читает `APP_BASE_HREF` формата `/country-language` (например, `/ru-ru`) и извлекает код языка из позиций 4-6.
2. **Инициализация переводов** — сервис вызывает `translate.addLangs(...)` с полным списком (`ru`, `en`, `es`, `fr`, `it`, `jp`, `ko`, `zh`, `de`) и применяет `translate.use(language)`.
3. **Рендеринг интерфейса** — все строки UI проходят через пайп `| translate`, подставляются значения из соответствующих JSON-файлов переводов.
4. **Выбор календарной локали** — геттер `calendarTranslate` возвращает объект локализации PrimeNG Calendar (`calendarRu`, `calendarEn`, ...) согласно текущему языку.
5. **Смена языка** — пользователь переходит по URL с другим префиксом (например, `/en-us/onlineboard`); приложение перезагружается с новым `baseHref`, и весь интерфейс отображается на выбранном языке.
6. **Редиректы с коротких кодов** — запросы на `/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de` перенаправляются на `/onlineboard` с сохранением языка из baseHref.
**Критерии приёмки:**
- ✅ Поддерживаются все 9 языков из enum `Language`.
- ✅ Текущий язык определяется из `APP_BASE_HREF` при старте приложения.
- ✅ Все тексты, метки кнопок и плейсхолдеры локализованы через `ngx-translate`.
- ✅ Календарь PrimeNG отображается на выбранном языке.
- ✅ Короткие языковые пути в роутинге ведут на `/onlineboard`.
**Примечание:** Переключатель языка находится в шапке внешней площадки aeroflot.ru и не входит в состав Angular-приложения; внутри модуля язык фиксируется через `baseHref`.
---
## US-3: Навигация по хлебным крошкам
**Цель:** Пользователь видит путь навигации на страницах результатов и деталей рейса и может вернуться на предыдущие уровни или на главную.
**Путь клиента:**
1. **Отображение компонента** — на страницах результатов поиска и деталей рейса рендерится `flights-page-breadcrumbs`, оборачивающий `<p-breadcrumb>` из PrimeNG.
2. **Формирование списка** — в `ngOnInit` компонент подписывается на `route.data` и получает массив `breadcrumbs` из `IRouteData`.
3. **Первый элемент** — в начало списка всегда подставляется `defaultMenuItem` с ярлыком `SHARED.MAIN` и ссылкой на `https://www.aeroflot.ru`.
4. **Локализация** — все элементы прогоняются через `translation.instant(item.label)`, ключи переводятся в текущий язык.
5. **Клик по элементу** — пользователь нажимает на любой уровень, PrimeNG `p-breadcrumb` выполняет навигацию по `url` элемента (внешний URL или внутренний путь).
6. **Отсутствие на главных страницах** — на стартовых страницах разделов и на `/flights-map` маршруты не содержат `breadcrumbs` в `data`, поэтому компонент не отображается.
**Критерии приёмки:**
- ✅ Хлебные крошки рендерятся только там, где в `route.data` есть массив `breadcrumbs`.
- ✅ Первый элемент всегда ведёт на `https://www.aeroflot.ru` с ярлыком из ключа `SHARED.MAIN`.
- ✅ Каждый элемент является кликабельной ссылкой.
- ✅ Метки переводятся на текущий язык интерфейса.
- ✅ На `/flights-map` и стартовых экранах разделов компонент отсутствует.
---
## US-4: Кнопка обратной связи
**Цель:** Пользователь может открыть форму обратной связи и отправить сообщение разработчикам приложения.
**Путь клиента:**
1. **Отображение кнопки** — в макете страницы присутствует компонент `feedback-button` с текстом из ключа `SHARED.FEEDBACK`.
2. **Клик по кнопке** — обработчик `showForm()` устанавливает `formVisible = true`, что активирует `*ngIf="formVisible"` и монтирует компонент `feedback-form`.
3. **Отображение формы** — на экране появляется блок `.feedback-form` с кнопкой закрытия и встроенным `<iframe>`, загружающим Microsoft Forms по фиксированному URL `https://forms.office.com/Pages/ResponsePage.aspx?...`.
4. **Заполнение формы** — пользователь взаимодействует с формой непосредственно внутри iframe; вся логика валидации и отправки обеспечивается Microsoft Forms.
5. **Закрытие формы** — клик по кнопке закрытия или клик за пределами формы (директива `clickOutside` у `feedback-form`) вызывает `hideForm()`, который снимает `formVisible`.
**Критерии приёмки:**
- ✅ Кнопка «Обратная связь» всегда видна на странице с локализованным текстом.
- ✅ Клик по кнопке показывает форму с iframe Microsoft Forms.
- ✅ Форма закрывается по клику вне её области или по кнопке закрытия.
- ✅ iframe загружается с тем же URL, что задан в шаблоне.
- ✅ Повторное открытие формы работает без перезагрузки страницы.
**Примечание:** Форма реализована через внешний iframe Microsoft Forms — собственной логики отправки в приложении нет.
---
## US-5: Кнопка прокрутки к началу страницы
**Цель:** Пользователь может одним кликом вернуться к верху страницы при длинном списке результатов.
**Путь клиента:**
1. **Монтирование компонента**`scroll-up-button` рендерит скрытый маркер `#observer-target` и блок кнопки с классами `scroll-up` / `scroll-up--visible`.
2. **Наблюдение за позицией** — в `ngOnInit` создаётся `IntersectionObserver` с `threshold: [1]`, который следит за пересечением `#observer-target` с вьюпортом.
3. **Показ кнопки при скролле** — когда `entry.boundingClientRect.top < 0` (цель ушла выше вьюпорта), `buttonVisible` становится `true` и добавляется модификатор `scroll-up--visible`.
4. **Резервная проверка** — каждые 500 мс вызывается `checkVisibility()`, которая сверяет позицию `.page-layout__header` и показывает кнопку, если шапка ушла выше вьюпорта.
5. **Клик по кнопке**`handleClick()` берёт элемент `flights-root`, вычисляет его `getBoundingClientRect().top` и вызывает `body.scrollTo(0, body.scrollTop + top)`, после чего скрывает кнопку.
6. **Уничтожение** — в `ngOnDestroy` наблюдатель и интервал очищаются.
**Критерии приёмки:**
- ✅ Кнопка не видна, пока шапка страницы находится во вьюпорте.
- ✅ Кнопка появляется, когда пользователь прокрутил страницу ниже шапки.
- ✅ Клик по кнопке возвращает скролл body к началу корня приложения.
- ✅ Класс `scroll-up--visible` корректно добавляется и снимается.
- ✅ Интервал и IntersectionObserver освобождаются при уничтожении компонента.
---
## US-6: Боковая панель с фильтрами
**Цель:** На странице Онлайн-Табло пользователь видит боковую панель с фильтрами поиска и может раскрывать нужные секции.
**Путь клиента:**
1. **Отображение секции** — компонент `online-board-filter` рендерит `<section class="frame">` с `<p-accordion>` от PrimeNG.
2. **Секция «Номер рейса»** — первый `p-accordionTab` (`data-testid="flight-filter"`) содержит заголовок из ключа `BOARD.FLIGHT_NUMBER`, иконку `arrow-down-icon` и вложенный `online-board-flight-number-filter`.
3. **Секция «Маршрут»** — второй `p-accordionTab` (`data-testid="route-filter"`) содержит заголовок `BOARD.ROUTE`, такую же иконку и `online-board-route-filter`.
4. **Раскрытие секции** — клик по заголовку вызывает событие `onOpen` аккордеона; обработчик `handleOpen($event)` запоминает активную секцию, иконка поворачивается (`rotated`).
5. **Сворачивание** — повторный клик вызывает `onClose` и `handleClose()`, секция сворачивается.
6. **Передача настроек** — во вложенные фильтры передаются `settings.boardMinDate` и `settings.boardMaxDate`, события `onSearch` пробрасываются через `handleFlightNumberSearch` и `handleRouteSearch`.
**Критерии приёмки:**
- ✅ В панели ровно две секции: «Номер рейса» и «Маршрут».
- ✅ Заголовки переведены через ключи `BOARD.FLIGHT_NUMBER` и `BOARD.ROUTE`.
- ✅ Секции раскрываются и сворачиваются кликом по заголовку.
- ✅ Активная секция отмечена повёрнутой иконкой `arrow-down-icon`.
- ✅ Каждая секция имеет `data-testid` (`flight-filter`, `route-filter`).
- ✅ При поиске из секции вызываются соответствующие обработчики.
---
## US-7: Информационные разделы на главной странице
**Цель:** На главной странице Онлайн-Табло пользователь видит блок популярных запросов и может одним кликом подставить параметры в форму поиска.
**Путь клиента:**
1. **Отображение блока** — компонент `popular-requests` рендерит контейнер `.popular-requests` с заголовком из ключа `BOARD.POPULAR-CHAPTERS`.
2. **Рендеринг карточек** — внутри контейнера последовательно выводятся до четырёх элементов `<popular-request>` с `*ngIf="requests[0..3]"` — каждая карточка показывается только при наличии данных.
3. **Содержимое карточки** — конкретный шаблон карточки выбирается компонентом `popular-request` в зависимости от типа запроса (flight-number, arrival, departure, route).
4. **Клик по карточке** — событие `onClick` поднимается в `popular-requests`, обработчик `handleRequestClick($event)` заполняет форму поиска параметрами выбранного запроса.
5. **Скрытие при активном поиске** — блок отображается только на стартовом экране раздела; после выполнения поиска родительский шаблон перестаёт его рендерить.
**Критерии приёмки:**
- ✅ Заголовок блока локализован через ключ `BOARD.POPULAR-CHAPTERS`.
- ✅ Максимум 4 карточки, каждая рендерится только при наличии данных.
- ✅ Клик по карточке вызывает `handleRequestClick` с данными запроса.
- ✅ Блок отсутствует на страницах результатов поиска.
- ✅ Карточки загружаются через `PopularRequestsModule` и связанные сервисы.
---
## US-8: История поиска
**Цель:** Пользователь видит свои последние поисковые запросы на главных страницах Онлайн-Табло и Расписания и может быстро повторить любой из них.
**Путь клиента:**
1. **Отображение секции** — компонент `search-history` рендерит `<section class="frame search-history">`, но только если `historyItems.length > 0`.
2. **Аккордеон** — внутри секции расположен `<p-accordion>` с одним `<p-accordionTab>`, заголовок которого содержит ключ `BOARD.YOU_SEARCH` и `arrow-down-icon`.
3. **Список элементов** — в содержимом таба через `*ngFor` выводятся `<search-history-item>` для каждой записи из `historyItems`.
4. **Клик по элементу** — обработчик `(click)="openHistoryUrl(item)"` выполняет переход по сохранённому URL запроса, восстанавливая параметры формы.
5. **Типы элементов** — поддерживаются разные шаблоны записей: `online-board-flight-number-history-item`, `online-board-route-history-item`, `schedule-item` — каждый отрисовывает свой набор полей.
6. **Отсутствие на карте полётов**`search-history` не включён в шаблон страницы `/flights-map`, поэтому там секция не показывается.
**Критерии приёмки:**
- ✅ Секция видна только при наличии хотя бы одной записи.
- ✅ Заголовок таба переведён через ключ `BOARD.YOU_SEARCH`.
- ✅ Каждая запись — отдельный `<search-history-item>` с собственным шаблоном.
- ✅ Клик по записи вызывает `openHistoryUrl(item)` и выполняет переход.
- ✅ На странице «Карта полётов» секция отсутствует.
---
## US-9: Локализация заголовка страницы и мета-тегов
**Цель:** Заголовок вкладки браузера и мета-теги страницы отображаются на языке, выбранном пользователем.
**Путь клиента:**
1. **Инициализация локали** — при загрузке модуля раздела `LocalizationService` уже содержит корректный `Language`, извлечённый из `APP_BASE_HREF`.
2. **Установка заголовка** — резолвер маршрута (`SettingsResolver`) подготавливает данные, а компоненты страницы через `Title` и `Meta` сервисы Angular устанавливают `document.title` и мета-теги.
3. **Источник строк** — ключи заголовков и описаний (например, `BOARD.TITLE`, `SCHEDULE.TITLE-TAB`, `FLIGHTS-MAP.TITLE`) берутся из JSON-файлов переводов через `translate.instant`.
4. **Применение языка** — значения подставляются в `<title>`, `<meta name="description">` и OpenGraph-теги на языке, соответствующем `baseHref`.
5. **Смена языка через URL** — при переходе на префикс другого языка приложение перезагружается, резолвер и компоненты формируют мета-информацию уже на новом языке.
**Критерии приёмки:**
-`document.title` содержит переведённую строку, соответствующую текущему разделу.
- ✅ Мета-теги `description`/`keywords` формируются из ключей перевода.
- ✅ Смена языка через URL-префикс обновляет title и meta.
- ✅ Все три раздела (`onlineboard`, `schedule`, `flights-map`) имеют собственные ключи заголовков.
- ✅ При отсутствии перевода подставляется fallback-значение ключа.
---
## US-10: Адаптивный дизайн
**Цель:** Интерфейс корректно отображается и используется на мобильных, планшетных и десктопных разрешениях.
**Путь клиента:**
1. **Мобильный экран** — при ширине вьюпорта меньше breakpoint'а CSS-стили перестраивают макет: боковая панель фильтров занимает всю ширину, таблица результатов переключается на компактный режим отображения карточек.
2. **Планшет** — на средних ширинах фильтры могут располагаться над списком результатов, шапка страницы и `page-tabs` сохраняют горизонтальное расположение.
3. **Десктоп** — на широких экранах фильтры отображаются в боковой колонке, таблица результатов занимает основную область, включаются tooltip-элементы PrimeNG.
4. **Изменение ширины окна** — пользователь меняет размер окна браузера, CSS media queries пересчитывают стили без перезагрузки страницы, компоненты PrimeNG адаптируются автоматически.
5. **Сохранение функциональности** — все действия (поиск, фильтрация, открытие деталей, обратная связь, прокрутка вверх) остаются доступными на любом устройстве.
**Критерии приёмки:**
- ✅ Макет перестраивается при пересечении CSS-breakpoint'ов без перезагрузки.
- ✅ Боковая панель фильтров адаптируется под узкие экраны.
- ✅ Вкладки `page-tabs` остаются кликабельными на мобильных устройствах.
- ✅ Таблицы/списки результатов читаемы на малых ширинах.
- ✅ Все функции приложения доступны на мобильных, планшетных и десктопных разрешениях.
---
## US-11: Отсутствие ошибок в консоли браузера
**Цель:** При типовых пользовательских сценариях в консоли браузера не появляются ошибки JavaScript и необработанные исключения.
**Путь клиента:**
1. **Открытие приложения** — пользователь заходит на `/onlineboard`; приложение инициализирует `LocalizationService`, `SettingsResolver` и модули без ошибок.
2. **Навигация по вкладкам** — переходы между `/onlineboard`, `/schedule` и (при включённом флаге) `/flights-map` не вызывают исключений в Router и ленивой загрузке модулей.
3. **Поиск и фильтрация** — заполнение форм, применение фильтров и открытие результатов не приводят к ошибкам HTTP-слоя, интерцепторов или PrimeNG-компонентов.
4. **Открытие деталей рейса** — переход на страницу деталей, обратная связь, прокрутка вверх, раскрытие истории поиска выполняются без предупреждений и ошибок.
5. **Проверка в DevTools** — пользователь открывает вкладку Console, выполняет сценарии и не видит сообщений уровня `error` или `unhandled promise rejection`.
**Критерии приёмки:**
- ✅ При начальной загрузке страницы в консоли нет ошибок.
- ✅ Навигация между всеми основными разделами не генерирует ошибок.
- ✅ Поиск, фильтрация и открытие деталей рейса завершаются без исключений.
- ✅ Интерактивные элементы (обратная связь, scroll-up, история) не выбрасывают ошибок.
- ✅ Отсутствуют предупреждения об устаревших API и необработанных промисах.
+229
View File
@@ -0,0 +1,229 @@
# Пользовательские истории: Онлайн доска полётов
## US-12: Поиск по номеру рейса
**Цель:** Пользователь хочет найти конкретный рейс, зная его номер и дату вылета.
**Путь клиента:**
1. **Открытие фильтра** — пользователь открывает аккордеон «Номер рейса» в панели фильтров онлайн-табло; активная вкладка выделяется цветом.
2. **Ввод номера** — в составном поле слева зафиксирован префикс «SU», справа — текстовое поле с плейсхолдером (пример «1234»), максимальная длина 5 символов.
3. **Валидация формата** — сервис проверяет регулярное выражение `^\d\d\d\d?[A-Za-z]?$`: 3–4 цифры и опциональный буквенный суффикс; при ошибке показывается тултип с сообщением.
4. **Выбор даты** — ниже расположен `calendar-input` с меткой «Дата вылета»; диапазон ограничен `boardMinDate`/`boardMaxDate`, недоступные даты заблокированы.
5. **Запуск поиска** — кнопка «Найти» активна; при успешной валидации вызывается `handleFlightNumberSearch`, происходит переход на страницу результатов.
6. **Очистка поля** — кнопка-крестик справа от ввода очищает номер одним кликом.
**Критерии приёмки:**
- ✅ Префикс «SU» всегда отображается и не редактируется.
- ✅ Валидация отклоняет буквы в цифровой части и номера длиннее 5 символов.
- ✅ Пустой номер показывает ошибку `FLIGHT_NUMBER-ERROR-EMPTY`.
- ✅ Некорректный формат показывает ошибку `FLIGHT_NUMBER-ERROR-ONLY-NUMBER`.
- ✅ Поиск не запускается, если не выбрана валидная дата.
- ✅ Кнопка очистки сбрасывает значение номера рейса.
---
## US-13: Выбор даты для поиска в онлайн-табло
**Цель:** Пользователь хочет указать дату вылета в пределах разрешённого окна онлайн-табло.
**Путь клиента:**
1. **Открытие календаря** — пользователь кликает по `calendar-input` с меткой «Дата вылета»; открывается поповер с месячной сеткой.
2. **Ограничение диапазона** — календарь принимает `minDate = boardMinDate` (вчера, −1 день) и `maxDate = boardMaxDate` (по умолчанию +7 дней от сегодня, настраивается через серверный контракт `boardSearchTo`).
3. **Блокировка недоступных дат** — массив `disabledDates` затемняет даты, по которым нет расписания, клик по ним игнорируется.
4. **Выбор даты** — при клике на активную дату значение записывается в модель фильтра, поповер закрывается.
5. **Отображение ошибки** — некорректное значение (пустое или невалидное) показывает сообщение `SHARED.DATE_FORMAT-WRONG` под полем.
6. **Синхронизация с результатами** — выбранная дата передаётся в страницу поиска и синхронизируется с компонентом `day-tabs`.
**Критерии приёмки:**
- ✅ Недоступны даты раньше `boardMinDate` и позже `boardMaxDate`.
- ✅ Даты из `disabledDates` визуально выключены и не кликабельны.
- ✅ Невалидная дата вызывает показ сообщения об ошибке.
- ✅ Выбранная дата сохраняется в модели и передаётся в URL при поиске.
- ✅ Календарь закрывается после выбора даты.
---
## US-14: Поиск по городу отправления
**Цель:** Пользователь хочет посмотреть все вылеты из указанного города на выбранную дату.
**Путь клиента:**
1. **Открытие аккордеона «Маршрут»** — раскрывается вкладка с формой маршрутного поиска внутри `online-board-filter`.
2. **Ввод города отправления** — компонент `city-autocomplete` с меткой «Город отправления» принимает ввод и показывает выпадающий список подсказок.
3. **Выбор из подсказок** — по мере набора сервис `cities-search-service` возвращает совпадения; клик по элементу подставляет город в поле.
4. **Оставление поля прибытия пустым** — для поиска только по отправлению пользователь не заполняет второе поле.
5. **Выбор даты и запуск поиска** — указывается дата в `calendar-input`, клик по «Найти» переходит на страницу результатов вылетов.
6. **Обработка ошибки** — если город не выбран из списка, показывается ошибка `departureError` под полем.
**Критерии приёмки:**
- ✅ Автодополнение работает при вводе минимум нескольких символов.
- ✅ Принимается только город, выбранный из списка подсказок.
- ✅ Ошибка отображается, если город введён, но не распознан.
- ✅ Поиск возможен только с заполненным полем отправления и валидной датой.
- ✅ Результаты содержат только рейсы с вылетом из указанного города.
---
## US-15: Поиск полётов с прибытием в конкретный город
**Цель:** Пользователь хочет увидеть все рейсы, прибывающие в указанный город на выбранную дату.
**Путь клиента:**
1. **Открытие маршрутного фильтра** — раскрывается вкладка «Маршрут» в `online-board-filter`.
2. **Ввод города прибытия** — во втором поле `city-autocomplete` с меткой «Город прибытия» пользователь вводит название.
3. **Выбор из автодополнения** — подсказки формируются сервисом, клик подставляет валидный город в модель.
4. **Пустое поле отправления** — поле отправления остаётся пустым для поиска по направлению прибытия.
5. **Ввод даты и запуск** — выбирается дата, клик «Найти» инициирует поиск типа прибытия.
6. **Переход на страницу прибытий** — результаты отображаются как список рейсов, прибывающих в город.
**Критерии приёмки:**
- ✅ Автодополнение выдаёт подсказки для города прибытия.
- ✅ При невалидном вводе показывается ошибка `arrivalError`.
- ✅ Поиск с заполненным только полем прибытия корректно запускается.
- ✅ Результаты ограничены рейсами с прибытием в указанный город.
- ✅ Выбранная дата передаётся в запрос.
---
## US-16: Поиск маршрута между двумя городами
**Цель:** Пользователь хочет найти рейсы между конкретной парой городов на выбранную дату.
**Путь клиента:**
1. **Заполнение отправления** — в `city-autocomplete` с меткой «Город отправления» выбирается город из подсказок.
2. **Заполнение прибытия** — во втором поле аналогично выбирается город назначения.
3. **Кнопка обмена** — между полями расположена кнопка со SVG-иконкой `changeCity`; клик вызывает метод `exchange()` и меняет значения полей местами.
4. **Выбор даты** — используется `calendar-input` с ограничениями `boardMinDate`/`boardMaxDate`.
5. **Опциональный фильтр времени** — под датой компонент `time-selector` в режиме `fullView=false` позволяет ограничить диапазон времени вылета.
6. **Запуск поиска** — кнопка «Найти» вызывает `handleRouteSearch`, результаты открываются на маршрутной странице.
**Критерии приёмки:**
- ✅ Оба города должны быть выбраны из автодополнения.
- ✅ Кнопка обмена меняет значения полей отправления и прибытия местами.
- ✅ Ошибки для полей отправления/прибытия отображаются независимо.
- ✅ Результаты содержат только рейсы между выбранной парой городов.
- ✅ Значение `timeRange` передаётся в запрос вместе с датой.
---
## US-17: Переключение дня вылета в результатах
**Цель:** Пользователь хочет быстро переключиться на соседний день, оставаясь в результатах поиска.
**Путь клиента:**
1. **Отображение дневных вкладок** — над списком результатов расположен компонент `day-tabs` с заголовком «Дата вылета».
2. **Генерация диапазона** — вкладки формируются между `boardMinDate` и `boardMaxDate` (до 16 дней), недоступные даты из `disabledDates` выключены.
3. **Выделение текущего дня** — выбранная дата (`searchDate`) визуально подсвечена как активная.
4. **Клик по другому дню** — событие `tabClick` вызывает `handleDateChanged($event)`, который перезапускает поиск для новой даты.
5. **Синхронизация с фильтром** — новая дата попадает в URL, значение в `calendar-input` фильтра обновляется одновременно.
**Критерии приёмки:**
- ✅ Вкладки отображаются только для дат в диапазоне `boardMinDate`..`boardMaxDate`.
- ✅ Даты из `disabledDates` отрисованы как неактивные и не кликабельны.
- ✅ Активная вкладка соответствует текущему `searchDate`.
- ✅ Клик по вкладке запускает новый поиск без полного перезагруза страницы.
- ✅ URL и состояние фильтра остаются согласованными с активной датой.
---
## US-18: Фильтр по времени вылета или прибытия
**Цель:** Пользователь хочет ограничить результаты рейсами в заданном временном окне.
**Путь клиента:**
1. **Проверка флага** — компонент `time-selector` в блоке результатов отображается только при включённом фичефлаге `RESULTS_TIME_SELECTOR`.
2. **Выбор метки** — метка динамически переключается между «Время вылета» и «Время прибытия» в зависимости от `requestType`.
3. **Режим полного отображения**`fullView=true`: слайдер с двумя ползунками и подписями начала/конца диапазона.
4. **Перетаскивание ползунков** — пользователь задаёт `timeFrom` и `timeTo`, значение пишется в модель `timeRange`.
5. **Завершение жеста** — событие `slideComplete` вызывает `handleTimeChanged()`, список рейсов перезапрашивается с новым диапазоном.
6. **Блокировка для «рейса по номеру»** — при `requestType === Flight` селектор визуально отключён.
**Критерии приёмки:**
- ✅ Селектор скрыт, если фичефлаг `RESULTS_TIME_SELECTOR` выключен.
- ✅ Метка соответствует типу запроса (вылет/прибытие).
- ✅ Перетаскивание ползунков обновляет модель `timeRange`.
- ✅ Новый запрос отправляется только после отпускания ползунка (`slideComplete`).
- ✅ Для поиска по номеру рейса селектор неактивен.
---
## US-19: Расширенный просмотр рейса в результатах
**Цель:** Пользователь хочет увидеть детальную информацию по конкретному рейсу прямо в списке результатов.
**Путь клиента:**
1. **Просмотр списка** — компонент `board-search-result` отображает список рейсов, каждый элемент кликабелен.
2. **Выделение текущего рейса** — ближайший к текущему времени рейс автоматически определяется утилитой `find-closest-flight` и помечается как `currentFlight`.
3. **Клик по рейсу** — событие `changeCurrentFlight` обновляет активный рейс в списке.
4. **Переход к деталям** — событие `toDetails` вызывает `handleToDetailsEvent($event)`, который навигирует на страницу `flight-details`.
5. **Сохранение контекста** — URL содержит параметры поиска, чтобы при возврате список сохранил состояние.
**Критерии приёмки:**
- ✅ Ближайший по времени рейс помечен как текущий при первом рендере.
- ✅ Клик по строке рейса меняет выделенный `currentFlight`.
- ✅ Переход к деталям сохраняет параметры исходного поиска.
- ✅ При возврате назад список результатов остаётся на той же позиции.
**Примечание:** В Angular-коде детальный просмотр реализован как отдельная страница `flight-details`, а не как инлайн-расширение строки.
---
## US-20: Обработка пустых результатов поиска
**Цель:** Пользователь должен получить понятное сообщение, если по его критериям рейсы не найдены.
**Путь клиента:**
1. **Завершение поиска** — компонент `online-board-search` получает пустой массив `flights` и скрывает список результатов.
2. **Показ заглушки** — вместо списка отображается `page-empty-list` со скроллом к заглушке (`scrollTo=true`).
3. **Особый случай для поиска по номеру** — если `requestType === Flight` и сервис вернул партнёров, вместо `page-empty-list` показывается `partners-redirect-note` со списком партнёров.
4. **Сохранение фильтров** — панель фильтра и дневные вкладки остаются видимыми, чтобы пользователь мог изменить критерии без перезагрузки.
5. **Повторный поиск** — изменение даты, времени или критерия автоматически инициирует новый запрос.
**Критерии приёмки:**
- ✅ При пустом списке рейсов и не-Flight запросе отображается `page-empty-list`.
- ✅ Для поиска по номеру рейса с партнёрами отображается `partners-redirect-note`.
- ✅ Панель фильтров и `day-tabs` остаются доступными для повторного поиска.
- ✅ Заглушка прокручивается в зону видимости пользователя.
- ✅ Изменение критерия сразу запускает новый запрос.
---
## US-22: Индикатор загрузки при поиске
**Цель:** Пользователь должен видеть чёткий индикатор процесса загрузки и иметь возможность отменить его.
**Путь клиента:**
1. **Запуск запроса** — при инициировании поиска `dataSource.loading` устанавливается в `true`.
2. **Блокировка фильтра и заголовка** — компонент `spin-lock` накладывается на `header-left` и `content-left`, блокируя взаимодействие с фильтром и вкладками.
3. **Полноэкранный лоадер** — в основной области показывается `page-loader` с кнопкой отмены запроса.
4. **Отмена поиска** — клик по кнопке отмены в `page-loader` вызывает `cancel()``handleRequestCancellation()`, прерывая запрос.
5. **Завершение** — при получении ответа `loading` становится `false`, лоадеры исчезают, отображается список рейсов либо пустая заглушка.
**Критерии приёмки:**
- ✅ Во время запроса отображаются `spin-lock` над фильтром и `page-loader` в контенте.
- ✅ Кнопка отмены в `page-loader` прерывает активный запрос.
- ✅ После отмены пользователь остаётся на странице с предыдущим результатом или пустым состоянием.
- ✅ После завершения загрузки все индикаторы скрываются.
- ✅ Повторный поиск корректно перезапускает цикл загрузки.
---
+272
View File
@@ -0,0 +1,272 @@
# Пользовательские истории: Поиск расписания полётов
**Важное примечание:** Функции этого раздела (обратный рейс, фильтры по времени, прямые рейсы) **специфичны для раздела "Расписание"** и не дублируют функции Онлайн-Табло.
---
## US-23: Переход на вкладку "Расписание"
**Цель:** Пользователь переходит на страницу поиска расписания, чтобы искать рейсы на определённую неделю с расширенными параметрами.
**Путь клиента:**
1. **Исходное состояние** — пользователь находится на странице `/onlineboard`, в навигации видны вкладки: Онлайн-Табло, Расписание, Карта полётов.
2. **Клик по вкладке "Расписание"** — пользователь кликает по вкладке "Расписание" в основной навигации.
3. **Переход по URL** — роутер меняет адрес на `/schedule`, соответствующий маршруту из `schedule-routing.module.ts`.
4. **Активация вкладки** — вкладка "Расписание" подсвечивается как активная, остальные — неактивные.
5. **Загрузка формы фильтра** — рендерится `ScheduleFilterComponent` с полями: города отправления/прибытия, неделя вылета, время вылета, чекбоксы "Только прямые рейсы" и "Обратный рейс".
6. **Автозаполнение по геолокации** — если `UserLocationService` вернул локацию и поле отправления пусто, оно заполняется кодом станции, `dateRange` ставится в текущую неделю.
**Критерии приёмки:**
- ✅ Клик по вкладке меняет URL на `/schedule`.
- ✅ Вкладка "Расписание" визуально отмечена как активная.
- ✅ Форма фильтра отображает все поля из Angular-шаблона.
- ✅ При разрешённой геолокации поле отправления предзаполняется.
- ✅ При наличии `urlParams.route` в резолвере форма инициализируется из URL.
**Примечание:** Инициализация также поддерживает hand-off через `StateService` (`schedulein`/`scheduleout`) — если параметры заданы, поиск запускается автоматически.
---
## US-24: Ввод города отправления
**Цель:** Пользователь вводит город или аэропорт отправления для поиска расписания.
**Путь клиента:**
1. **Фокус на поле** — пользователь кликает в поле "Город отправления" (`city-autocomplete`, `data-testid="schedule-departure-city-input"`).
2. **Ввод текста** — пользователь вводит название города или IATA-код (например, "Мос" или "SVO").
3. **Автодополнение** — компонент `city-autocomplete` запрашивает справочник и показывает выпадающий список совпадений.
4. **Выбор значения** — пользователь выбирает пункт списка, поле привязывается к `state.departure` через `ngModel`.
5. **Валидация кода** — сеттер `departure` вызывает `updateCalendar()`, который через `validationService.validateCode()` проверяет код станции.
6. **Обновление календаря** — если оба кода валидны, вызывается `apiService.getFlightDaysByRoute()` и формируется массив `disabledDates`.
**Критерии приёмки:**
- ✅ Поле отображает плейсхолдер `SHARED.CITY_PLACEHOLDER`.
- ✅ Автодополнение возвращает совпадения при вводе не менее 2 символов.
- ✅ Выбранное значение сохраняется в `ScheduleFiltersStateService.departure`.
- ✅ Невалидный код приводит к ошибке `departureError` при сабмите.
- ✅ После валидного выбора обновляется список `disabledDates` в календаре вылета.
**Примечание:** Компонент `city-autocomplete` и валидатор `StationCodeValidationService` общие для всех фич проекта.
---
## US-25: Ввод города прибытия
**Цель:** Пользователь вводит город или аэропорт прибытия, чтобы задать маршрут поиска.
**Путь клиента:**
1. **Фокус на поле** — пользователь кликает в поле "Город прибытия" (`data-testid="schedule-arrival-city-input"`).
2. **Ввод текста** — вводит название или IATA-код города назначения.
3. **Автодополнение**`city-autocomplete` подгружает и показывает список подходящих станций.
4. **Выбор значения** — выбранное значение записывается в `state.arrival` через двустороннюю привязку.
5. **Пересчёт календаря** — сеттер вызывает `updateCalendar()`, который проверяет оба кода и обновляет `disabledDates``disabledDatesReturn` при активном `withReturn`).
6. **Проверка "город A ≠ город B"** — при сабмите валидация отклоняет одинаковые города с ошибкой `SHARED.ARRIVAL-EQUALS-DEPARTURE-ERROR`.
**Критерии приёмки:**
- ✅ Поле использует тот же компонент `city-autocomplete`, что и отправление.
- ✅ Выбранное значение сохраняется в `state.arrival`.
- ✅ Одинаковые коды отправления и прибытия блокируют сабмит с ошибкой.
- ✅ После валидного выбора обновляется `disabledDates` обоих календарей (если `withReturn`).
- ✅ Невалидный код вызывает `arrivalError` с сообщением `SHARED.ARRIVAL-CITY-ERROR`.
---
## US-26: Кнопка "Обменять" (swap)
**Цель:** Пользователь одним кликом меняет местами города отправления и прибытия.
**Путь клиента:**
1. **Заполненные поля** — пользователь ввёл города отправления и/или прибытия.
2. **Клик по кнопке ⇄** — кликает кнопку `button-change` между полями городов (SVG `changeCity`).
3. **Сброс ошибок** — метод `exchange()` обнуляет `departureError` и `arrivalError`.
4. **Обмен значений** — деструктуризацией `[departure, arrival] = [arrival, departure]` значения меняются местами.
5. **Каскадное обновление** — сеттеры вызывают `updateCalendar()`, что перерасчитывает `disabledDates` для нового направления.
6. **Отображение** — поля автокомплита отображают поменянные значения.
**Критерии приёмки:**
- ✅ Клик по кнопке меняет значения `state.departure` и `state.arrival`.
- ✅ Ошибки валидации городов сбрасываются.
- ✅ Календарь `disabledDates` пересчитывается под новое направление.
- ✅ Работает корректно если одно из полей пустое (пустота и значение меняются).
- ✅ Кнопка доступна всегда (не disabled).
---
## US-27: Выбор недели вылета
**Цель:** Пользователь выбирает неделю, для которой хочет посмотреть расписание рейсов.
**Путь клиента:**
1. **Клик по календарю** — пользователь кликает `calendar-input-week` с `label="SHARED.SCHEDULES_DATE"` (`data-testid="schedule-calendar"`).
2. **Показ календаря** — открывается read-only календарь с ограничениями `minDate=settings.scheduleMinDate`, `maxDate=maxScheduleDate`.
3. **Отображение disabled дат** — дни без рейсов подсвечиваются серым согласно `disabledDates`, рассчитанному из `getFlightDaysByRoute`.
4. **Выбор недели** — пользователь кликает на день; компонент выбирает всю неделю (пн–вс) и сохраняет её в `state.dateRange`.
5. **Сброс ошибки**`ngModelChange` вызывает `resetDateRangeError()`.
6. **Каскад на обратный рейс** — если включён `withReturn`, сеттер пересчитывает `minReturnScheduleDate` для календаря возврата.
**Критерии приёмки:**
- ✅ Календарь работает в режиме выбора недели, а не одного дня.
- ✅ Даты за пределами `scheduleMinDate`/`scheduleMaxDate` недоступны.
- ✅ Дни без рейсов отображаются как disabled.
- ✅ Выбранный диапазон сохраняется как `[Date, Date]` в `state.dateRange`.
- ✅ Предыдущая ошибка `dateRangeError` сбрасывается при новом выборе.
**Примечание:** `scheduleMinDate`/`scheduleMaxDate` задаются в `AppSettings`; `maxScheduleDate` дополнительно ограничен датой возврата при `withReturn`.
---
## US-28: Поиск туров (туда и обратно)
**Цель:** Пользователь включает режим "Обратный рейс" для поиска туров с указанием даты возврата.
**Путь клиента:**
1. **Клик по чекбоксу** — пользователь отмечает чекбокс `SHARED.RETURN_FLIGHT_VIEW`, что выставляет `state.withReturn = true`.
2. **Отображение блока возврата**`*ngIf="withReturn"` показывает блок с календарём `SHARED.RETURN_FLIGHT_DATE` и селектором времени `SHARED.RETURN_FLIGHT_TIME`.
3. **Обновление календаря возврата** — сеттер `withReturn` вызывает `updateCalendarReturn()`, который запрашивает `getFlightDaysByRoute` для обратного направления (arrival → departure).
4. **Выбор недели возврата** — пользователь выбирает диапазон в календаре возврата, значение сохраняется в `state.returnDateRange`.
5. **Сброс при отключении** — при снятии чекбокса `resetReturnDateRange()` очищает `returnDateRange` в `[]`.
6. **Сабмит с inbound** — при клике "Показать" формируется `inboundParams` с обращённым направлением и передаётся в URL.
**Критерии приёмки:**
- ✅ Чекбокс не отмечен по умолчанию.
- ✅ Блок возврата появляется только при `withReturn === true`.
-`disabledDatesReturn` рассчитывается для обратного направления.
- ✅ Снятие чекбокса очищает `returnDateRange`.
- ✅ URL содержит второй сегмент с параметрами inbound при сабмите.
**Примечание:** `minReturnScheduleDate` не может быть раньше выбранной даты вылета; `maxScheduleDate` подстраивается под дату возврата.
---
## US-29: Фильтр "Только прямые рейсы"
**Цель:** Пользователь ограничивает поиск только прямыми рейсами, исключая стыковки.
**Путь клиента:**
1. **Клик по чекбоксу** — пользователь отмечает `SHARED.DIRECT_FLIGHT_ONLY`, это выставляет `state.directOnly = true`.
2. **Пересчёт disabledDates** — сеттер вызывает `updateCalendar()` с аргументом `!directOnly === false`, ограничивая запрос `getFlightDaysByRoute` только прямыми рейсами.
3. **Обновление календаря** — календарь помечает дни без прямых рейсов как disabled.
4. **Формирование параметров** — в `getOutboundParams()` добавляется `connections: 0` (иначе `undefined`).
5. **Сабмит** — пользователь нажимает "Показать", URL получает суффикс `-C0`.
6. **Отображение результатов** — страница результатов показывает только прямые рейсы.
**Критерии приёмки:**
- ✅ Чекбокс не отмечен по умолчанию.
- ✅ При включении флага параметр `connections` равен `0`.
- ✅ URL маршрута содержит `-C0` в сегменте параметров.
-`disabledDates` пересчитывается под режим "только прямые".
- ✅ Флаг применяется и к outbound, и к inbound (если `withReturn`).
---
## US-30: Фильтр по времени вылета
**Цель:** Пользователь ограничивает поиск рейсами, вылетающими в заданное время суток.
**Путь клиента:**
1. **Открытие селектора** — пользователь кликает `time-selector` с лейблом `SHARED.DEPARTURE_TIME` (режим `fullView=false`).
2. **Выбор диапазона** — компонент позволяет выбрать `timeFrom` и `timeTo` (например, утро/день/вечер/ночь или вручную).
3. **Сохранение в state** — выбранный `IUrlTimeRange` сохраняется в `state.timeRange`.
4. **Формирование параметров**`getOutboundParams()` добавляет `timeFrom`/`timeTo` через spread `...this.timeRange`.
5. **Построение URL**`url-builder.formatRouteParams()` добавляет к сегменту суффикс `-{timeFrom}{timeTo}` при наличии обоих значений.
6. **Сабмит** — пользователь нажимает "Показать", фильтр применяется к результатам.
**Критерии приёмки:**
- ✅ Селектор отображается в компактном режиме (`fullView=false`).
- ✅ Значение сохраняется как `{timeFrom, timeTo}` в `state.timeRange`.
- ✅ Оба значения обязательны для добавления в URL.
- ✅ URL содержит временной суффикс в сегменте параметров outbound.
- ✅ При отсутствии выбора фильтр не применяется (параметры опущены).
---
## US-31: Фильтр по времени прибытия (обратный рейс)
**Цель:** Пользователь задаёт время вылета для обратного сегмента при поиске туров.
**Путь клиента:**
1. **Условие отображения** — селектор времени возврата виден только при `withReturn === true`.
2. **Открытие селектора** — пользователь кликает `time-selector` с лейблом `SHARED.RETURN_FLIGHT_TIME`.
3. **Выбор диапазона** — выбирается диапазон `timeFrom`/`timeTo` для обратного рейса.
4. **Сохранение** — значение записывается в `state.returnTimeRange` как `IUrlTimeRange`.
5. **Формирование inbound**`getInboundParams()` добавляет `...this.returnTimeRange` к параметрам обратного направления.
6. **URL** — URL получает второй сегмент с собственным временным суффиксом для inbound.
**Критерии приёмки:**
- ✅ Селектор недоступен, пока не включён `withReturn`.
- ✅ Значение сохраняется в `state.returnTimeRange`.
- ✅ Время применяется только к inbound-сегменту, не к outbound.
- ✅ URL содержит отдельные временные суффиксы для каждого направления.
- ✅ Отключение `withReturn` не влияет на сохранённый `timeRange` outbound.
**Примечание:** Название "время прибытия" условно — в Angular это `SHARED.RETURN_FLIGHT_TIME`, фактически время вылета обратного рейса.
---
## US-32: Валидация параметров поиска
**Цель:** Система проверяет корректность параметров перед переходом на страницу результатов.
**Путь клиента:**
1. **Клик "Показать"** — пользователь нажимает кнопку `SHARED.SCHEDULES_VIEW` (`data-testid="schedule-search-button"`).
2. **Сбор параметров**`viewSchedule()` собирает `outbound` и `inbound` параметры из state.
3. **Вызов validate**`ScheduleFilterValidationService.validate({inbound, outbound})` очищает предыдущие ошибки и запускает проверку.
4. **Проверка outbound** — проверяются: валидность `departure`, валидность `arrival`, различие городов, валидность `dateFrom`/`dateTo` через `moment`.
5. **Проверка inbound** — при наличии inbound проверяются `dateFrom`/`dateTo`.
6. **Навигация или ошибка** — при успехе URL строится и вызывается `router.navigateByUrl()`; при ошибке соответствующее поле показывает сообщение.
**Критерии приёмки:**
- ✅ Пустое поле отправления → ошибка `SHARED.DEPARTURE-CITY-ERROR`.
- ✅ Пустое поле прибытия → ошибка `SHARED.ARRIVAL-CITY-ERROR`.
- ✅ Совпадение городов → ошибка `SHARED.ARRIVAL-EQUALS-DEPARTURE-ERROR`.
- ✅ Невалидная дата → ошибка `SHARED.WEEK_FORMAT-WRONG` на соответствующем поле.
- ✅ При ошибке переход не выполняется, форма остаётся активной.
**Примечание:** Валидация кодов станций делегируется `StationCodeValidationService` и выполняется асинхронно.
---
## US-33: URL-параметры поиска расписания
**Цель:** Параметры поиска сериализуются в URL, чтобы ссылки можно было шарить и перезагружать страницу без потери состояния.
**Путь клиента:**
1. **Построение URL** — после валидации `ScheduleUrlBuilderService.getRoutePageUrl(outbound, inbound?)` формирует путь `${base}/route/{outboundSeg}[/{inboundSeg}]`.
2. **Форматирование сегмента**`formatRouteParams()` собирает строку `{departure}-{arrival}-{dateFrom}-{dateTo}[-{timeFrom}{timeTo}][-C{connections}]`.
3. **Навигация**`router.navigateByUrl(url)`, а при успехе `SearchHistoryService.add()` сохраняет запись типа `schedule-route`.
4. **Разбор URL** — при прямом открытии ссылки резолвер использует `url-parser.service` для преобразования сегментов в `IScheduleRouteParams`.
5. **Инициализация формы**`setState(routeParams)` заполняет `state` значениями из URL (включая `withReturn = true` при наличии inbound).
6. **Построение запроса к API**`request-builder.service` превращает `IScheduleRouteDirectionParams` в HTTP-запрос к бэкенду.
**Критерии приёмки:**
- ✅ URL содержит сегмент outbound в формате `DEP-ARR-dateFrom-dateTo[-timeTimeTo][-C0]`.
- ✅ При `withReturn` добавляется второй сегмент с inbound параметрами.
- ✅ Прямое открытие URL восстанавливает все поля формы.
- ✅ Отсутствие inbound сегмента выставляет `withReturn = false`.
- ✅ История поиска получает запись при успешной навигации.
**Примечание:** Базовый путь фичи задаётся через токен `SCHEDULE_URL_BASE`; даты форматируются методом `formatDate` базового класса `UrlBuilderService`.
---
+167
View File
@@ -0,0 +1,167 @@
# Пользовательские истории: Результаты расписания и детали полётов
## US-35: Страница результатов расписания
**Цель:** Пользователь видит сгруппированные по дням рейсы на выбранную неделю с возможностью переключаться между неделями и направлениями без возврата на форму поиска.
**Путь клиента:**
1. **Переход из формы поиска** — после отправки формы пользователь попадает на `schedule-search-view` с параметрами маршрута и диапазоном недели.
2. **Загрузка данных** — пока идут запросы к API, показывается индикатор загрузки (`loading`).
3. **Отображение заголовка результатов**`schedule-search-result-header` показывает маршрут и элементы сортировки.
4. **Вкладки недель** — под заголовком расположен `week-tabs` (понедельник–воскресенье) с активной текущей неделей.
5. **Список дней**`schedule-days` рендерит аккордеон по дням недели; по умолчанию раскрыт день `state.selectedDate`, иначе сегодня, иначе первый непустой.
6. **Карточки рейсов** — внутри раскрытого дня `list-scheduled-flight` показывает рейсы, первый из которых развёрнут по умолчанию.
7. **Переключение направления** — при наличии обратного рейса пользователь может переключать `OUTBOUND`/`INBOUND` через контрол направления.
**Критерии приёмки:**
- ✅ URL содержит параметры маршрута и диапазона дат недели
- ✅ Заголовок, week-tabs, schedule-days и карточки рейсов отрисованы
- ✅ Первый день с рейсами раскрыт автоматически
- ✅ При пустом расписании показывается `page-empty-list`
- ✅ Переключение OUTBOUND/INBOUND меняет отображаемый набор рейсов
- ✅ Макет адаптивен под мобильные экраны
---
## US-36: Переключение между днями в расписании
**Цель:** Пользователь выбирает конкретный день недели в аккордеоне `schedule-days` и видит рейсы только за этот день.
**Путь клиента:**
1. **Просмотр списка дней**`schedule-days` отображает по одному `p-accordionTab` на каждый день текущей недели.
2. **Автораскрытие дня** — при загрузке метод `expandDefaultGroup` раскрывает день из `state.selectedDate`, иначе сегодня, иначе первый с рейсами.
3. **Клик по заголовку дня** — пользователь кликает на свёрнутый `schedule-search-result-day`, срабатывает `(onOpen)`.
4. **Загрузка содержимого вкладки**`list-scheduled-flight` монтируется только при `item.expanded && item.flights.length`.
5. **Автоскролл к раскрытому дню** — используется `scrollTo` с задержкой `getScrollDelay` (500 мс на каждые 100 рейсов).
6. **Развёртывание первого рейса**`expandDefaultFlight` автоматически раскрывает первый рейс в открытой группе.
7. **Пустой день** — если в дне нет рейсов, показывается `page-empty-list` вместо списка.
**Критерии приёмки:**
- ✅ Каждый день недели — отдельная вкладка аккордеона
- ✅ День по умолчанию раскрывается согласно приоритету: selectedDate → сегодня → первый непустой
- ✅ При открытии вкладки первый рейс раскрывается автоматически
- ✅ Срабатывает плавная прокрутка к раскрытому дню
- ✅ Пустые дни показывают компонент заглушки
---
## US-37: Навигация между неделями (week-tabs)
**Цель:** Пользователь переключает отображаемую неделю расписания через карусель вкладок `week-tabs`, соблюдая границы `scheduleMinDate`/`scheduleMaxDate`.
**Путь клиента:**
1. **Отображение карусели недель**`week-tabs` наследуется от `DateSelectorBaseComponent` и генерирует вкладки по неделям (понедельник–воскресенье).
2. **Метки вкладок**`getTabLabel` возвращает строку вида "дд.мм – дд.мм", `getTabMobileLabel` — полный формат для мобильных.
3. **Подсветка активной недели** — вкладка, содержащая `dateFrom` из `routeParams`, помечается как `active`.
4. **Ограничение диапазона** — вкладки вне `[minScheduleDate, maxScheduleDate]` получают `disabled: true` и не кликаются.
5. **Выбор недели** — пользователь кликает активную вкладку, эмитится `IWeekTabValue { dateFrom, dateTo }`.
6. **Обновление routeParams**`handleDateChanged` вызывает `routeParamsChange` или `returnRouteParamsChange` в зависимости от направления.
7. **Перезагрузка дней**`schedule-days` перерисовывается для новой недели, раскрывается день по умолчанию.
**Критерии приёмки:**
- ✅ Недели считаются от понедельника до воскресенья
- ✅ Активная неделя подсвечивается, недоступные — задизейблены
- ✅ Клик по вкладке обновляет `routeParams.dateFrom/dateTo`
- ✅ Границы ограничены `settings.scheduleMinDate` и `scheduleMaxDate`
- ✅ После смены недели обновляется список дней и рейсов
---
## US-38: Расширенный просмотр полёта в расписании
**Цель:** Пользователь раскрывает карточку конкретного рейса в `list-scheduled-flight`, чтобы увидеть детали маршрута без перехода на отдельную страницу.
**Путь клиента:**
1. **Список рейсов дня**`list-scheduled-flight` получает `scheduleItem` и отсортированный `flights` по `sortBy`.
2. **Свёрнутые карточки** — все рейсы, кроме одного активного (`this.flight`), показаны в компактном виде.
3. **Клик по рейсу** — метод `toggle(index)` переключает `flight.expanded`.
4. **Сворачивание предыдущего** — если пользователь открывает другой рейс, предыдущий активный сворачивается автоматически (`this.flight.expanded = false`).
5. **Отрисовка тела по типу маршрута** — в зависимости от `RouteTypeLegacy` показывается прямое тело, `connecting-flight-body` или `multi-flight-body`.
6. **Ресортировка** — при изменении `sortBy` массив `flights` пересортировывается через `sortFlights`.
7. **Переход к деталям** — клик по ссылке/кнопке деталей эмитит `toDetails` с `IFlight`.
**Критерии приёмки:**
- ✅ В один момент раскрыта ровно одна карточка рейса
- ✅ Повторный клик по активной карточке сворачивает её
- ✅ Изменение sortBy перестраивает порядок карточек
- ✅ Тип тела выбирается по `RouteTypeLegacy` рейса
- ✅ Событие `toDetails` пробрасывается наверх с моделью рейса
---
## US-39: Сортировка результатов расписания
**Цель:** Пользователь сортирует рейсы внутри дня по времени вылета, длительности или времени прибытия в обоих направлениях.
**Путь клиента:**
1. **Отображение контролов сортировки**`schedule-search-result-header` показывает переключатели с текущим `sortBy` (по умолчанию `arrivalDown`).
2. **Выбор режима** — пользователь кликает по одному из шести режимов `ScheduleSortMode`: `departureUp`, `departureDown`, `timeUp`, `timeDown`, `arrivalUp`, `arrivalDown`.
3. **Эмиссия события**`onSort(mode)` вызывает `sortByChange.emit(mode)`.
4. **Проброс в списки** — новое значение `sortBy` приходит в `list-scheduled-flight` каждого дня.
5. **Пересортировка**`ngOnChanges` вызывает `sortFlights(this.flights, this.sortBy)` из `sort-flights-by.pipe`.
6. **Обновление UI** — карточки рейсов перерисовываются в новом порядке, активная карточка сохраняется по `hash`.
**Критерии приёмки:**
- ✅ Поддерживаются ровно 6 режимов из `ScheduleSortMode`
- ✅ По умолчанию используется `arrivalDown`
- ✅ Смена режима пересортировывает массив `flights` в каждом дне
-`trackBy` по `flight.hash` предотвращает полную перерисовку
- ✅ Контрол сортировки доступен на странице результатов
---
## US-42: Рейсы с пересадками и многосегментные
**Цель:** Пользователь корректно видит рейсы с одной или несколькими пересадками, включая все сегменты маршрута.
**Путь клиента:**
1. **Определение типа маршрута**`list-scheduled-flight` выбирает тело по `RouteTypeLegacy`: `Direct`, `Connecting`, `MultiLeg`.
2. **Connecting flight**`connecting-flight-body` в `ngOnChanges` вычисляет `departure = getFirstLeg(flight).departure`, `arrival = getLastLeg(flight).arrival`, и `legs = getLegs(flight)`.
3. **Стратегия сегментов**`getFlightLegs` получает сегменты через `getStrategy(flight).getLegs(flight)`, что позволяет различать типы маршрутов.
4. **MultiLeg flight**`multi-flight-body` принимает `MultiLegFlight` и отдаёт `flight.legs` напрямую через геттер `legs`.
5. **Отрисовка сегментов** — каждый `Leg` показывается с временем, аэропортом, номером рейса и длительностью пересадки между ними.
6. **Переход к деталям** — при клике любой `toDetails` эмитится наверх через `handleToDetailsEvent`.
**Критерии приёмки:**
- ✅ Рейсы с пересадкой используют `connecting-flight-body`
- ✅ Многосегментные рейсы используют `multi-flight-body`
- ✅ Отображаются все сегменты маршрута через `legs`
- ✅ Первая и последняя станция берутся из первого и последнего `Leg`
- ✅ Событие `toDetails` пробрасывается из вложенных тел наверх
---
## US-46: Кнопка "Назад" на странице деталей
**Цель:** На странице деталей полёта пользователь возвращается на предыдущую страницу результатов (расписание или онлайн-табло) по контекстной кнопке "Назад".
**Путь клиента:**
1. **Переход к деталям** — пользователь кликает рейс в `list-scheduled-flight` или на онлайн-табло, происходит навигация к странице деталей с переданным `viewType`.
2. **Отрисовка кнопки**`details-back` получает `@Input() viewType: ViewType` и рендерит одну из двух кнопок.
3. **Ветка Schedule** — при `viewType === ViewType.Schedule` показывается кнопка с локализованной меткой `SHARED.BACK-SCHEDULE`.
4. **Ветка Onlineboard** — при `viewType === ViewType.Onlineboard` показывается кнопка с меткой `SHARED.BACK-BOARD`.
5. **Клик по кнопке** — вызывается `redirectSchedule()` или `redirectBoard()` соответственно.
6. **Навигация** — методы вызывают `RouterHandlerService.toSchedule()` / `toBoard()`, что возвращает пользователя на соответствующую страницу результатов.
**Критерии приёмки:**
- ✅ Кнопка показывает текст, соответствующий `viewType`
- ✅ Одновременно отрисовывается только одна кнопка (через `*ngIf`)
- ✅ Клик вызывает `RouterHandlerService.toSchedule()` или `toBoard()`
- ✅ Иконка `pi pi-arrow-left` отображается слева от метки
- ✅ Компонент использует `ChangeDetectionStrategy.OnPush`
---
+338
View File
@@ -0,0 +1,338 @@
# Пользовательские истории: Детали полёта и расширенная информация
## US-40: Услуги на борту
**Цель:** Пользователь видит набор услуг, предоставляемых на конкретном рейсе, чтобы оценить комфорт перед поездкой.
**Путь клиента:**
1. **Переход к странице деталей** — пользователь открывает страницу деталей рейса из онлайн-табло или расписания.
2. **Прокрутка к секции услуг** — в блоке деталей отображается секция `flight-details-services` с заголовком «Услуги» (`SHARED.SERVICE`) и иконкой раздела.
3. **Отображение списка услуг** — каждая услуга из массива `onBoardServices` рендерится компонентом `flight-details-icon` с иконкой (`getServiceIconSrc`) и названием (`service.title`).
4. **Переход по ссылке услуги** — у каждой услуги есть ссылка (`getServiceUrl(service.id)`), ведущая на описание услуги на сайте авиакомпании.
5. **Пустой список** — если у рейса нет услуг в массиве, секция не отображает отдельные элементы, повторяя поведение Angular-шаблона с `*ngFor`.
**Критерии приёмки:**
- ✅ Секция «Услуги» отображается с иконкой и заголовком из словаря `SHARED.SERVICE`.
- ✅ Каждая услуга показывает иконку и название из модели рейса.
- ✅ Клик по иконке услуги открывает ссылку с описанием.
- ✅ Список строится из `onBoardServices`, без подстановки данных вне модели.
- ✅ При отсутствии услуг секция корректно скрывает элементы списка.
---
## US-41: Расписание полёта и время
**Цель:** Пользователь видит расписанное время вылета и прибытия, продолжительность полёта и дни выполнения, чтобы понимать регулярность рейса.
**Путь клиента:**
1. **Открытие блока «Расписание рейса»** — компонент `flight-schedule` выводит аккордеон с заголовком `SHARED.SCHEDULE-FLIGHT`.
2. **Краткая информация в шапке** — в свернутом виде показан заголовок; при раскрытии отображаются блоки времени (`time-group-legacy`) вылета и прибытия по расписанию с оффсетом пояса.
3. **Продолжительность полёта** — отдельным свойством выводится `SHARED.PATH-TIME` с компонентом `duration`, привязанным к `flight.duration`.
4. **Дни выполнения** — в теле аккордеона расположен блок `SHARED.DAYS-EXECUTE-FLIGHT` с семью днями недели; активные дни определяются методом `isDayActive('1'..'7')`.
5. **Примечание о часовом поясе** — под списком дней отображается `note` с текстом `SHARED.NOTE-TIME-SCHEDULE`, где подставлены реальные даты.
**Критерии приёмки:**
- ✅ Секция «Расписание рейса» реализована в виде аккордеона с одним открытым элементом.
- ✅ Отображаются запланированное время вылета и прибытия с учётом часового пояса.
- ✅ Показывается длительность полёта через компонент `duration`.
- ✅ Дни недели выводятся с классом `inactive` для неактивных дней.
- ✅ Под днями показывается нота с замещёнными датами.
---
## US-47: Страница деталей полёта
**Цель:** Пользователь попадает на страницу деталей конкретного рейса и видит цельный макет со всеми подсекциями информации о полёте.
**Путь клиента:**
1. **Переход с карточки рейса** — клик по рейсу в расписании или онлайн-табло вызывает роутинг на `flight-details-page` (или `schedule-flight-details-page`).
2. **Загрузка данных** — страница использует `dataSource` со свойствами `flight`, `flightLegacy`, `flightsLegacy`, `loading`; пока `loading` истинно, показывается индикатор загрузки.
3. **Мета-теги и SEO** — рендерится компонент `*-flight-details-meta-tags`, формирующий JsonLD/OpenGraph для страницы.
4. **Заголовок и обёртка**`details-view` (online-board или schedule вариант) принимает проекцию `title` и выводит соответствующий `flight-details-title`.
5. **Секции деталей** — внутри обёртки последовательно отображаются заголовок (header), статус маршрута, полное расписание маршрута (`flight-details-full-route`), расписание (`flight-schedule`), воздушное судно, услуги и другие секции.
6. **Обработка событий** — страница подписана на `open` (навигация по сегментам) и `dateChange` (смена даты в онлайн-табло) через обработчики компонента.
**Критерии приёмки:**
- ✅ При переходе на URL рейса страница рендерится c нужным `dataSource`.
- ✅ Пока данные не загружены, пользователю показан индикатор загрузки.
- ✅ Рендерится компонент мета-тегов для SEO.
- ✅ В макет проецируются заголовок и все секции деталей, предусмотренные Angular-шаблоном.
- ✅ События `open` и `dateChange` корректно пробрасываются в обработчики страницы.
---
## US-48: Основная информация о полёте
**Цель:** Пользователь видит «шапку» деталей: бейдж рейса, быстрые действия, события рейса и время последнего обновления.
**Путь клиента:**
1. **Отображение бейджа** — для онлайн-табло `board-details-header` выводит `details-header-badge` с флагами `round: false`, `large: true`, без статуса; для расписания `schedule-details-header` использует `details-header-badges` для нескольких сегментов.
2. **Блок действий с полётом** — рядом с бейджем рендерится `flight-actions` с соответствующим набором кнопок (в расписании скрыта регистрация, в онлайн-табло показан статус).
3. **События рейса** — в онлайн-табло отображается `flight-events` с направлением `column-mobile`, описанием, флагами `changeRoute` и `reroute`.
4. **Время последнего обновления** — в правой части (или внизу) рендерится `last-update` для рейса c соответствующим `viewType`.
5. **Ветка connecting-рейсов** — в `schedule-details-header` при `isConnecting` бейджи показываются с круглыми иконками и статусом, а кнопка статуса в `flight-actions` отключается.
**Критерии приёмки:**
- ✅ Бейдж рейса отображается в соответствии с `viewType` (онлайн-табло или расписание).
- ✅ Кнопки действий конфигурируются по правилам: в расписании без регистрации, в онлайн-табло без отдельной детализации.
- ✅ В онлайн-табло показан компонент событий рейса.
- ✅ Показан блок `last-update` с временем последнего обновления.
- ✅ Для connecting-рейсов в расписании используется набор бейджей с флагом `canShowStatus`.
---
## US-49: Статус и детали статуса
**Цель:** Пользователь видит текущий статус рейса на линии маршрута и связанную с ним информацию о времени в полёте.
**Путь клиента:**
1. **Линия маршрута** — компонент `route-status` получает `leg` и при наличии рейса рисует полосу (`status--line`) с иконкой статуса (`flight-status-icon`).
2. **Статус в полёте** — при `inFlight` ширина индикатора равна `leg.flightPercent`, а текст статуса берётся из словаря `FLIGHT-STATUSES.*` и позиционируется в соответствии с классами `inFlightStatusClasses`.
3. **Статус завершённого рейса** — при `finished` текст статуса выравнивается по правому краю с классом `status--text-right`.
4. **Статус начинающегося рейса** — при `starting` текст статуса выравнивается по левому краю с классом `status--text-left`.
5. **Время в полёте и оставшееся время** — при `inFlight` под линией показаны `SHARED.TRAVEL-TIME` (с `leg.duration | duration`) и `SHARED.TIME-LEFT` (с `remainingFlightDuration | duration`), если значения доступны.
6. **Мультисегментность** — для мультилег-рейсов иконка статуса берётся из текущего `leg.status`, иначе из `flight.leg.status`.
**Критерии приёмки:**
- ✅ Компонент `route-status` рендерится только при наличии `leg`.
- ✅ Статус локализуется через ключ `FLIGHT-STATUSES.<status>`.
- ✅ Для летящего рейса ширина индикатора соответствует `leg.flightPercent`.
- ✅ Тексты времени показываются только при доступных `duration` и `remainingFlightDuration`.
- ✅ В мультилег-режиме используется статус текущего сегмента.
---
## US-50: Информация о воздушном судне
**Цель:** Пользователь видит тип самолёта и его характеристики для онлайн-табло, а также ссылку на страницу модели.
**Путь клиента:**
1. **Блок «Самолёт»** — компонент `flight-details-airplane` рендерится внутри `flight-props` с иконкой компании и свойством `SHARED.PLANE`.
2. **Ссылка на тип самолёта**`title` отображается как ссылка `getPlaneLink()`, открывающаяся в новой вкладке.
3. **Название самолёта** — в режиме `ViewType.Onlineboard` при наличии `aircraft.name` показывается свойство `AIRPLANE.NAME`.
4. **Конфигурация мест** — при наличии `seats` отображаются свойства «Всего мест», «Эконом», «Комфорт» и «Бизнес» (`AIRPLANE.SEATS-TOTAL/ECONOMY/COMFORT/BUSINESS`) только для ненулевых значений.
5. **Предыдущий рейс борта** — при наличии `previous` выводится `BOARD.PREVIOUS-FLIGHT`: либо ссылка на детали предыдущего рейса (если `showPrevious`), либо просто номер рейса через пайп `flightNumber`.
**Критерии приёмки:**
- ✅ Блок самолёта всегда показывает заголовок `SHARED.PLANE` и ссылку на тип.
- ✅ Остальные поля (имя, места, предыдущий рейс) показываются только в `ViewType.Onlineboard` и только при наличии данных.
- ✅ Места выводятся по классам: total, economy, comfort, business.
- ✅ Предыдущий рейс отображается как ссылка только при `showPrevious`.
- ✅ Отсутствующие данные не создают пустых строк.
---
## US-51: Информация об авиакомпании
**Цель:** Пользователь видит визуальный логотип оператора рейса и, при необходимости, подпись «Авиакомпания».
**Путь клиента:**
1. **Отображение логотипа** — компонент `operator-logo` рендерит блок `company-logo` с классами, вычисляемыми из оператора рейса (свойство `classes`).
2. **Подпись блока** — при входном флаге `caption` сверху показывается описание `SHARED.AVIACOMPANY`.
3. **Подсказка оператора** — блок логотипа содержит атрибут `pTooltip` с названием `operatingBy`, что при наведении показывает человеко-читаемое название авиакомпании.
4. **Атрибут для тестирования** — у элемента задан `data-testid="flight-company-logo"` для автотестов и селекторов.
5. **Встраивание в заголовок**`operator-logo` используется в заголовках деталей, карточках списков и бейджах, всегда через CSS-спрайт/фоновую картинку, без `<img>`-элемента.
**Критерии приёмки:**
- ✅ Логотип выводится только через CSS-классы `company-logo` + оператор.
- ✅ Подпись `SHARED.AVIACOMPANY` показывается только при активном флаге `caption`.
- ✅ На наведении отображается тултип с именем оператора.
- ✅ У элемента присутствует `data-testid="flight-company-logo"`.
- ✅ Компонент не создаёт лишнего контента внутри `company-logo`.
---
## US-52: Информация об аэропортах
**Цель:** Пользователь видит город, терминал и, при изменении, старое название/терминал для станции вылета и прибытия.
**Путь клиента:**
1. **Отображение города** — компонент `station` выводит название города через `text` с параметрами выравнивания, размера и жирности (`cityStyles`) и включённым `ellipsis`.
2. **Тултип города** — при наведении на название показывается полный `city` через `tooltip`.
3. **Ссылка на терминал** — под городом выводится `terminal-link`, связанный с `station`, что даёт пользователю информацию о номере терминала.
4. **Отображение старого терминала** — при наличии `oldStation` показывается второй `terminal-link` с флагом `oldValue`, визуально отмечающим предыдущий терминал.
5. **Отображение старого города** — при `shouldShowOldCity` выводится `text` с `oldCity` красного цвета и размера 12, сигнализируя пользователю об изменении станции.
6. **Использование в заголовках и списках** — компонент переиспользуется в бейджах деталей, списках рейсов и секциях маршрута, сохраняя одинаковое поведение.
**Критерии приёмки:**
- ✅ Название города отображается с усечением и тултипом.
- ✅ Терминал выводится через компонент `terminal-link` для текущей станции.
- ✅ При смене станции показывается второй `terminal-link` для старого значения.
- ✅ При смене города показывается старое название красным цветом.
- ✅ Компонент корректно переиспользуется во всех местах, где он встраивается в Angular-приложении.
---
## US-53: Дни работы рейса
**Цель:** Пользователь видит, в какие дни недели выполняется рейс, по данным из модели.
**Путь клиента:**
1. **Блок «Дни выполнения»** — внутри аккордеона `flight-schedule` рендерится секция с заголовком `SHARED.DAYS-EXECUTE-FLIGHT`.
2. **Семь дней недели** — в контейнере `days` выводятся семь элементов с ключами `DAYS.1..DAYS.7` (пн–вс согласно локализации).
3. **Активность дня** — каждому элементу присваивается класс `inactive`, если `isDayActive('<n>')` возвращает `false`.
4. **Примечание о времени** — под днями выводится компонент `note` с текстом `SHARED.NOTE-TIME-SCHEDULE`, где `replaceDates` подставляет реальные диапазоны дат.
5. **Отсутствие собственных иконок** — секция выводит только текстовые ярлыки дней без дополнительных иконок или календаря.
**Критерии приёмки:**
- ✅ Секция отображает ровно семь дней недели.
- ✅ Неактивные дни получают CSS-класс `inactive`.
- ✅ Активность определяется через метод `isDayActive(n)`.
- ✅ Под списком дней выводится примечание с подстановкой дат.
- ✅ Секция присутствует только внутри `flight-schedule` и не дублируется в других блоках.
---
## US-54: Действия с полётом
**Цель:** Пользователь может воспользоваться набором действий над рейсом (печать, поделиться, купить, регистрация, статус, подробнее), соответствующих контексту страницы.
**Путь клиента:**
1. **Рендер `flight-actions`** — компонент принимает флаги `print`, `share`, `buy`, `register`, `status`, `details`, `wide` и рейс `flight`, а также `viewType`.
2. **Кнопка печати** — при `print` отображается `print-button` с данными рейса для вывода страницы на печать.
3. **Кнопка «Поделиться»** — при `share` отображается `share-button`, открывающий `share-panel` со ссылкой `getDetailsAbsoluteUrl(flight, viewType)` (US-62).
4. **Кнопка покупки** — при `buy && canBuy` отображается `buy-ticket-button`, использующий `flight` как `direct` для передачи параметров.
5. **Кнопка регистрации** — при `register && canRegister` отображается `registration-button` с рейсом, доступный только для поддерживаемых рейсов.
6. **Кнопки статуса и деталей** — при `status && canViewStatus` выводится `flight-status-button`; при `details``flight-details-button`, эмитирующий событие `toDetails` через обработчик `handleToDetailsEvent`.
**Критерии приёмки:**
- ✅ Кнопки `buy`, `register`, `status` рендерятся при истинности входного флага И соответствующего `can*`-условия (`canBuy`, `canRegister`, `canViewStatus`). Кнопки `print`, `share`, `details` зависят только от входного флага.
-`share-button` открывает панель шаринга с абсолютной ссылкой на детали.
-`buy-ticket-button` получает `flight` как `direct`.
-`flight-details-button` эмитит событие `toDetails`, обработанное страницей.
- ✅ При флаге `wide` добавляется разделитель `k-space` перед блоком покупки.
---
## US-55: Схема маршрута / полная карточка маршрута
**Цель:** Пользователь видит полную визуализацию маршрута рейса с таймлайном сегментов.
**Путь клиента:**
1. **Обёртка секции** — компонент `flight-details-full-route` рендерит элемент `section.frame`, группирующий содержимое блока маршрута.
2. **Таймлайн** — внутри `section.frame` выводится компонент `timeline` с массивом `legs`, показывающий последовательность сегментов рейса.
3. **Возможность изменения сегментов** — флаг `canChange` у таймлайна равен `viewType === ViewType.Onlineboard`, включая интерактивность только на онлайн-табло.
4. **Отключённый `flight-brief`** — в шаблоне присутствует закомментированный через `*ngIf="false"` компонент `flight-brief` (отключён по задаче 8072); он не отображается.
5. **Использование на обеих страницах** — компонент применяется и в онлайн-табло, и в расписании с разницей только в `viewType`.
**Критерии приёмки:**
- ✅ Секция `flight-details-full-route` всегда содержит один `timeline` c переданным `legs`.
- ✅ Флаг `canChange` таймлайна зависит от `viewType`.
- ✅ Компонент `flight-brief` остаётся отключённым (`*ngIf="false"`).
- ✅ Секция обёрнута в `section.frame` без дополнительных блоков.
- ✅ На расписании таймлайн рендерится в неинтерактивном режиме.
---
## US-56: Информация о пересадках
**Цель:** Пользователь видит информацию о пересадке или промежуточной посадке между сегментами маршрута.
**Путь клиента:**
1. **Условие показа** — компонент `transfer` отображается только при наличии `transfer.type` (`MultiLeg` или `Connecting`).
2. **Иконка типа пересадки** — по типу выбирается SVG: `intermediate-landing` для `MultiLeg` или `flight-transfer` для `Connecting`.
3. **Название типа** — выводится через пайп `transferType`, передающий `[transfer]` и `transfer.type`.
4. **Длительность пересадки** — рядом с иконкой времени (`time-orange.svg`) рендерится `transfer-time`, получающий `arrival`, `departure` и тип (`actual` для онлайн-табло, `scheduled` для расписания).
5. **Времена прибытия и вылета** — блок `transfer-times` показывает два `time-group-legacy` (прилёт и вылет) через тире.
6. **Станция пересадки** — компонент `transfer-section` рендерит детали станции пересадки, скрывая блок вылета при `stationChange === 'noChange'`.
**Критерии приёмки:**
- ✅ Компонент не рендерит содержимое, если `transfer.type` отсутствует.
- ✅ Для каждого типа пересадки используется соответствующая SVG-иконка.
- ✅ Длительность пересадки вычисляется компонентом `transfer-time` с правильным режимом по `viewType`.
- ✅ Отображаются два времени (прилёт и вылет) через разделитель « — ».
- ✅ При `stationChange === 'noChange'` блок вылета в `transfer-section` скрывается.
---
## US-62: Поделиться
**Цель:** Пользователь может поделиться ссылкой на конкретный рейс, отправив её через панель шаринга.
**Путь клиента:**
1. **Кнопка «Поделиться»** — компонент `share-button` отображает круглую прозрачную кнопку с иконкой `svg--share` и локализованной меткой `BOARD.SHARE`.
2. **Тултип** — при наведении над кнопкой показывается подсказка `BOARD.SHARE` сверху.
3. **Открытие панели** — клик вызывает метод `share($event)`, который переключает `share-panel` (`togglePanel`) передавая ссылку рейса.
4. **Формирование ссылки**`getFlightLink` возвращает `navigate.getDetailsAbsoluteUrl(flight, viewType)` при наличии рейса, иначе — текущий `window.location.href`.
5. **Использование во `flight-actions`** — кнопка встраивается только если задан флаг `share`, всегда получает `flight` и `viewType`.
**Критерии приёмки:**
- ✅ Кнопка рендерится с тултипом и меткой `BOARD.SHARE`.
- ✅ Клик открывает `share-panel` с актуальной ссылкой рейса.
- ✅ Ссылка формируется через `RouterHandlerService.getDetailsAbsoluteUrl`.
- ✅ Если рейс не передан, используется текущий URL окна.
- ✅ Компонент встраивается через `flight-actions` по флагу `share`.
---
## US-63: Отсутствие ошибок на странице деталей
**Цель:** Страница деталей стабильно загружается и рендерится без ошибок во всех режимах (online-board, schedule, connecting).
**Путь клиента:**
1. **Инициализация страницы**`flight-details-page` (или `schedule-flight-details-page`) подписывается на `dataSource` и корректно обрабатывает начальное состояние `loading: true`.
2. **Отсутствующие поля** — компоненты `flight-details-airplane`, `operator-logo`, `transfer` проверяют наличие данных (`*ngIf`) перед рендером, что исключает ошибки при отсутствии значений.
3. **Условные ветки статуса**`route-status` рендерится только при наличии `leg` и выбирает ветку (`inFlight`/`finished`/`starting`) без падений.
4. **Действия** — кнопки во `flight-actions` активируются только при соответствующих `can*`-флагах, предотвращая вызов недоступных действий.
5. **События страницы** — обработчики `handleOpenEvent`, `handleDateChange`, `handleToDetailsEvent` получают события без выбрасывания ошибок.
6. **Верификация через автотесты** — существующие `*.spec.ts` для `route-status`, `flight-actions`, `share-button`, `print-button`, `operator-logo` подтверждают стабильность компонентов.
**Критерии приёмки:**
- ✅ При загрузке страницы деталей не возникает ошибок в консоли.
- ✅ Все условные блоки корректно скрываются при отсутствии данных.
- ✅ Обработчики событий страницы отрабатывают без исключений.
- ✅ Действия выполняются только при активных `can*`-флагах.
- ✅ Unit-тесты ключевых компонентов проходят.
---
## US-64: Целостность данных
**Цель:** Данные, отображаемые на странице деталей, соответствуют модели рейса, без выдумывания значений и без рассинхронизации между секциями.
**Путь клиента:**
1. **Единый источник данных** — страница получает `flight`, `flightLegacy`, `flightsLegacy` из `dataSource` и передаёт их в дочерние компоненты без трансформаций, несущих потерю данных.
2. **Сегменты маршрута**`flight-details-full-route` использует `legs` напрямую; `route-status` работает с конкретным `leg`, сохраняя согласованность статуса и процента прогресса.
3. **Расписание и дни**`flight-schedule` отображает `flight.duration`, `departureLeg.departure.times.scheduled`, `arrivalLeg.arrival.times.scheduled` и массив активных дней через `isDayActive`.
4. **Самолёт и услуги**`flight-details-airplane` использует поля `aircraft.name`, `seats`, `previous`; `flight-details-services` перебирает `onBoardServices` — без фолбэков на «типовые» значения.
5. **Пересадки**`transfer` читает `transfer.type`, `arrival`, `departure`, `stationChange` и показывает ровно ту станцию и время, которые присутствуют в данных.
6. **Кнопки действий**`flight-actions` использует `flight` и предикаты `canBuy`, `canRegister`, `canViewStatus`, чтобы не показывать действия, недоступные для данной записи.
**Критерии приёмки:**
- ✅ Все отображаемые поля читаются непосредственно из модели рейса.
- ✅ Ни одна секция не подставляет значения «по умолчанию» при отсутствии данных.
- ✅ Секции маршрута, расписания и статуса согласованы по сегментам `legs`.
- ✅ Услуги, места и предыдущий рейс отображаются только при наличии соответствующих полей.
- ✅ Кнопки действий отражают реальные возможности рейса через `can*`-флаги.
---
+314
View File
@@ -0,0 +1,314 @@
# Пользовательские истории: Карта полётов
## US-65: Переход на вкладку "Карта полётов"
**Цель:** Пользователь может открыть раздел с интерактивной картой маршрутов полётов в качестве альтернативы табличному расписанию.
**Путь клиента:**
1. **Шаг 1 — пользователь на главной странице** — открыта вкладка "Онлайн-Табло" или "Расписание", в заголовке виден навигационный блок с тремя разделами.
2. **Шаг 2 — видит третью вкладку** — в заголовке отображается вкладка "Карта полётов" рядом с "Онлайн-Табло" и "Расписание" (только если включён feature flag).
3. **Шаг 3 — кликает по вкладке** — происходит переход на страницу карты, URL меняется на маршрут карты полётов.
4. **Шаг 4 — страница инициализируется** — отображается индикатор загрузки (`isLoading = true`), подгружаются справочники городов и аэропортов.
5. **Шаг 5 — карта готова к работе** — после `ngAfterViewInit` и `dictService.ready$` карта инициализируется, индикатор загрузки скрывается, пользователь видит фильтр и карту с маркерами.
**Критерии приёмки:**
- ✅ Вкладка "Карта полётов" видна только при включённом feature flag.
- ✅ Клик по вкладке переводит пользователя на страницу карты без перезагрузки приложения.
- ✅ До готовности справочников отображается индикатор загрузки.
- ✅ После инициализации отображаются компоненты `flights-map-filter` и `flights-map-body`.
- ✅ Если feature flag выключен, вкладка не рендерится в навигации.
**Примечание:** Функция скрыта за feature flag (`features.flightsMap`) и по умолчанию отключена. Карта построена на Leaflet.
---
## US-66: Отображение маршрутов на карте
**Цель:** Пользователь видит на интерактивной карте маршруты полётов Аэрофлота в виде линий между городами.
**Путь клиента:**
1. **Шаг 1 — открытие карты** — пользователь на странице "Карта полётов", инициализируется компонент `FlightsMapBodyComponent` с Leaflet-картой (центр `[53, 45]`, zoom 5).
2. **Шаг 2 — отрисовка маркеров городов** — из справочника `dictService.citiesAll` на карте размещаются маркеры городов (иконка `markerBlueSmall`) с подписями-тултипами.
3. **Шаг 3 — загрузка маршрутов** — после выбора фильтров выполняется запрос `apiService.getDestinations(...)`, возвращающий массив маршрутов.
4. **Шаг 4 — построение линий** — прямые рейсы отрисовываются сплошной линией (`directRoutePolyLine`, цвет `#2457ff`), рейсы с пересадками — пунктиром (`dashRoutePolyLine`, цвет `#2433ff`, `dashArray: '4 14'`).
5. **Шаг 5 — дуги большого круга** — каждая линия строится как геодезическая дуга (`buildGreatCircle`) из 64 сегментов между координатами городов.
6. **Шаг 6 — группировка на слое** — все полилинии добавляются на `destinationsLayer` поверх базовой тайловой подложки.
**Критерии приёмки:**
- ✅ Карта инициализируется с базовым тайловым слоем (`L.tileLayer`, zoom 36).
- ✅ Прямые маршруты отображаются сплошной линией синего цвета (`#2457ff`).
- ✅ Маршруты с пересадками отображаются пунктирной линией (`#2433ff`, `dashArray: '4 14'`).
- ✅ Линии строятся по дуге большого круга, а не прямыми отрезками на плоскости.
- ✅ При смене фильтров старые линии очищаются (`destinationsLayer.clearLayers()`) и рисуются заново.
**Примечание:** Все маршруты используют оттенки синего — различие только в стиле линии (сплошная/пунктир). Других цветов кода не предусмотрено.
---
## US-67: Выбор города отправления
**Цель:** Пользователь выбирает город вылета и сразу видит на карте все доступные направления из этого города ("паук").
**Путь клиента:**
1. **Шаг 1 — фокус на поле "Откуда"** — пользователь кликает по полю `city-autocomplete` с label `SHARED.DEPARTURE_CITY` в фильтре.
2. **Шаг 2 — ввод запроса** — начинает вводить название города, компонент автодополнения показывает подходящие варианты из справочника.
3. **Шаг 3 — выбор города** — выбирает вариант, в `FlightsMapFiltersStateService` вызывается `setDeparture(code)`.
4. **Шаг 4 — подсветка маркера** — маркер выбранного города переключается на иконку `markerOrange` и переносится в `highlightedLayer`.
5. **Шаг 5 — запрос направлений** — без города прибытия срабатывает `fetchAndDrawSpider(departure, dateFrom, dateTo)` — запрос всех доступных направлений на 6 месяцев вперёд.
6. **Шаг 6 — отрисовка "паука"** — для каждого уникального города назначения строится прямая линия от города отправления (`directRoutePolyLine`).
**Критерии приёмки:**
- ✅ Поле "Откуда" использует компонент `city-autocomplete` с автодополнением по справочнику городов.
- ✅ Выбор города вызывает `filterStateService.setDeparture(code)`.
- ✅ Маркер выбранного города меняет иконку на оранжевую.
- ✅ При выборе только города отправления рисуется "паук" из всех доступных направлений.
- ✅ Если геолокация пользователя определена и фильтры пусты, город отправления подставляется автоматически.
**Примечание:** Клик по маркеру города на карте также устанавливает его как отправление через `handleMarkerClick`.
---
## US-68: Выбор города прибытия
**Цель:** Пользователь уточняет направление, выбирая город прибытия, чтобы увидеть конкретные маршруты между двумя точками.
**Путь клиента:**
1. **Шаг 1 — город отправления уже выбран** — в поле "Откуда" указан город, на карте виден "паук" направлений.
2. **Шаг 2 — фокус на поле "Куда"** — пользователь кликает по второму `city-autocomplete` с label `SHARED.ARRIVAL_CITY`.
3. **Шаг 3 — выбор города** — из автодополнения выбирается город, вызывается `setArrival(code)`.
4. **Шаг 4 — подсветка обоих маркеров** — маркеры отправления и прибытия получают иконку `markerOrange` и переносятся в `highlightedLayer`.
5. **Шаг 5 — запрос маршрута** — срабатывает `fetchAndDrawRoute(departure, arrival, dateFrom, dateTo, connections)`; если прямых рейсов нет, автоматически делается повторный запрос с `connections: 1`.
6. **Шаг 6 — отрисовка маршрута** — отображаются все найденные маршруты (прямые сплошной линией, с пересадками — пунктиром) и показываются popup в точках отправления и прибытия.
**Критерии приёмки:**
- ✅ Поле "Куда" работает только после выбора города отправления.
- ✅ Выбор города вызывает `filterStateService.setArrival(code)`.
- ✅ Оба маркера (отправления и прибытия) подсвечиваются оранжевой иконкой.
- ✅ При отсутствии прямых рейсов автоматически делается fallback-запрос с пересадками.
- ✅ Если fallback вернул рейсы, тумблер "Соединительные" автоматически включается (`setConnections(true)`).
**Примечание:** Клик по маркеру при уже выбранном отправлении автоматически устанавливает его как прибытие.
---
## US-69: Обмен городов
**Цель:** Пользователь может одним кликом поменять местами города отправления и прибытия, чтобы посмотреть обратное направление.
**Путь клиента:**
1. **Шаг 1 — оба города выбраны** — в полях "Откуда" и "Куда" указаны два разных города, на карте видны маршруты.
2. **Шаг 2 — видит кнопку обмена** — между двумя полями отображается кнопка `.button-change` со SVG-иконкой `#changeCity`.
3. **Шаг 3 — клик по кнопке** — вызывается метод `exchange()` компонента фильтра.
4. **Шаг 4 — сброс ошибок валидации**`validationService.departureError` и `validationService.arrivalError` обнуляются.
5. **Шаг 5 — обмен значений** — значения полей меняются местами через деструктуризацию `[departure, arrival] = [arrival, departure]`.
6. **Шаг 6 — перерисовка маршрута** — сервис состояния эмитит изменения, `watchRouteChanges` запускает новый запрос и карта обновляется.
**Критерии приёмки:**
- ✅ Кнопка обмена видна между полями "Откуда" и "Куда".
- ✅ Клик меняет местами значения двух `city-autocomplete`.
- ✅ Ошибки валидации обоих полей сбрасываются при обмене.
- ✅ Маршрут на карте автоматически перестраивается для нового направления.
- ✅ Обмен доступен даже если заполнено только одно из полей (второе станет пустым).
---
## US-70: Выбор даты
**Цель:** Пользователь выбирает дату полёта, чтобы сформировать корректную ссылку на бронирование и контекст для popup маршрута.
**Путь клиента:**
1. **Шаг 1 — фильтр развёрнут** — в нижней части фильтра виден блок `flighs-map-filter-date` с компонентом `calendar-input`.
2. **Шаг 2 — клик по полю даты** — открывается календарь с ограничениями `minDate`, `maxDate` и `disabledDates` из состояния фильтра.
3. **Шаг 3 — выбор даты** — пользователь кликает по доступному дню, вызывается `filterStateService.setDate(date)`.
4. **Шаг 4 — реакция карты**`watchDateChanges` через `distinctUntilChanged` по полю `date` ловит изменение.
5. **Шаг 5 — обновление popup** — если на карте уже есть маршруты (`destinations.data.routes.length > 0`), вызывается `showRoutePopup(routes)` с новой датой.
6. **Шаг 6 — обновление ссылки "Купить билет"** — в popup прибытия ссылка `getLink()` пересобирается с новой датой в формате `YYYYMMDD`.
**Критерии приёмки:**
- ✅ Поле даты — компонент `calendar-input` с label `SHARED.FLIGHT_DATE`.
- ✅ Календарь учитывает `minDate`, `maxDate`, `disabledDates` из состояния фильтра.
- ✅ Изменение даты вызывает `filterStateService.setDate(date)`.
- ✅ При наличии маршрутов popup с ссылкой на бронирование обновляется под новую дату.
- ✅ Изменение даты не приводит к повторному запросу списка маршрутов (карта не меняется, меняется только ссылка).
**Примечание:** Сам запрос направлений использует окно `[вчера; +6 месяцев]` — выбранная дата влияет только на ссылку "Купить билет".
---
## US-71: Фильтр "Внутренние рейсы"
**Цель:** Пользователь может отключить или включить отображение внутрироссийских маршрутов на карте.
**Путь клиента:**
1. **Шаг 1 — фильтр открыт** — в блоке `flights-map-filter-content-checkboxes` виден тумблер `toggle-switch` с label `FLIGHTS-MAP.DOMESTIC_FLIGHTS`.
2. **Шаг 2 — тумблер активен** — тумблер доступен только при выбранном городе отправления (`[disabled]="departure ? false : true"`).
3. **Шаг 3 — клик по тумблеру** — вызывается `filterStateService.setDomestic(value)`.
4. **Шаг 4 — обновление слоёв маркеров**`updateMarkers` скрывает слои российских городов (`zoomLayers.ru`), когда тумблер "Международные" включён без "Внутренних".
5. **Шаг 5 — пересчёт маршрутов**`filterRoutes` применяет предикат `isDomestic` и оставляет только маршруты, где все города из `ruCitiesCodes`.
6. **Шаг 6 — перерисовка карты** — линии и маркеры обновляются на новом наборе.
**Критерии приёмки:**
- ✅ Тумблер "Внутренние рейсы" отображается в блоке фильтров.
- ✅ Тумблер заблокирован, если не выбран город отправления.
- ✅ Клик вызывает `filterStateService.setDomestic(value)`.
- ✅ При активном фильтре на карте остаются только маршруты между российскими городами.
- ✅ Маркеры нероссийских городов скрываются/показываются в соответствии со значением тумблера.
---
## US-72: Фильтр "Международные рейсы"
**Цель:** Пользователь может отключить или включить отображение международных маршрутов.
**Путь клиента:**
1. **Шаг 1 — фильтр открыт** — в блоке фильтров виден второй тумблер `toggle-switch` с label `FLIGHTS-MAP.INTERNATIONAL_FLIGHTS`.
2. **Шаг 2 — доступность тумблера** — работает только при выбранном городе отправления.
3. **Шаг 3 — клик по тумблеру** — вызывается `filterStateService.setInternational(value)`.
4. **Шаг 4 — обновление видимости слоёв**`updateMarkers` скрывает слои с российскими маркерами, если включены только международные.
5. **Шаг 5 — фильтрация маршрутов**`filterRoutes` применяет предикат `isInternational` (хотя бы один город из `otherCitiesCodes`).
6. **Шаг 6 — перерисовка** — линии и маркеры на карте обновляются.
**Критерии приёмки:**
- ✅ Тумблер "Международные рейсы" отображается в блоке фильтров.
- ✅ Тумблер заблокирован, если не выбран город отправления.
- ✅ Клик вызывает `filterStateService.setInternational(value)`.
- ✅ При одновременно выключенных "Внутренних" и активных "Международных" остаются только маршруты с зарубежным городом.
- ✅ Комбинация обоих тумблеров (оба ON) показывает все маршруты без предикатов категории.
---
## US-73: Фильтр "Соединительные рейсы"
**Цель:** Пользователь управляет показом маршрутов с пересадками между выбранными городами.
**Путь клиента:**
1. **Шаг 1 — выбраны отправление и прибытие** — тумблер `toggle-switch` с label `FLIGHTS-MAP.CONNECTING_FLIGHTS` активируется (`[disabled]="departure && arrival ? false : true"`).
2. **Шаг 2 — клик по тумблеру** — вызывается `filterStateService.setConnections(value)`.
3. **Шаг 3 — новый запрос**`fetchAndDrawRoute` вызывается с параметром `connections: 1`.
4. **Шаг 4 — фильтрация на клиенте**`filterRoutes` применяет предикат `hasConnections` (`!r.isDirect`), если тумблер активен.
5. **Шаг 5 — отрисовка пунктирных линий** — найденные маршруты с пересадками рисуются стилем `dashRoutePolyLine`.
6. **Шаг 6 — автоматическое включение** — если прямых рейсов нет, система сама делает fallback-запрос и включает тумблер (`skipNextFetchOnce` предотвращает повторный запрос).
**Критерии приёмки:**
- ✅ Тумблер "Соединительные рейсы" отображается в блоке фильтров.
- ✅ Тумблер доступен только если выбраны оба города.
- ✅ Клик вызывает `filterStateService.setConnections(value)`.
- ✅ При включённом тумблере на карте отображаются только непрямые маршруты пунктиром.
- ✅ При отсутствии прямых рейсов тумблер включается автоматически, и это не вызывает повторного сетевого запроса.
---
## US-74: Зуммирование карты
**Цель:** Пользователь может изменять масштаб карты стандартными средствами Leaflet для более детального или общего просмотра.
**Путь клиента:**
1. **Шаг 1 — карта инициализирована** — начальный zoom = 5, ограничения тайлового слоя `maxZoom: 6`, `minZoom: 3`.
2. **Шаг 2 — изменение масштаба** — пользователь использует колесо мыши, кнопки `+`/`-` или жест щипка на мобильном.
3. **Шаг 3 — событие `zoomend`** — Leaflet эмитит событие, подписка вызывает `updateVisibility()`.
4. **Шаг 4 — перерасчёт видимости маркеров** — в `updateMarkers` сравнивается текущий zoom с уровнем слоя (`zoomLayers[countryType][z]`): слои с уровнем `<= z` добавляются, остальные удаляются.
5. **Шаг 5 — логика тултипов** — при `zoom <= 3` все неподсвеченные тултипы закрываются; при наличии двух подсвеченных маркеров неактивные тоже скрываются; иначе — все тултипы открываются.
**Критерии приёмки:**
- ✅ Начальный zoom карты равен 5, диапазон 3–6.
- ✅ Управление масштабом работает через стандартные средства Leaflet (колесо, кнопки, жесты).
- ✅ При изменении zoom видимость маркеров пересчитывается в соответствии с уровнем категории города.
- ✅ При `zoom <= 3` отображаются только тултипы подсвеченных маркеров.
- ✅ Тайловый слой не запрашивает тайлы вне диапазона `3..6`.
**Примечание:** Видимость маркера зависит от `cityCategoryService.zoomLevel(code)` — крупные города видны на меньших масштабах.
---
## US-75: Панорамирование карты
**Цель:** Пользователь может перемещать карту drag-ом, оставаясь в пределах заданных географических границ.
**Путь клиента:**
1. **Шаг 1 — карта инициализирована** — при создании Leaflet-карты задаются `maxBounds: [[-70, -185], [80, 200]]` и `maxBoundsViscosity: 1`.
2. **Шаг 2 — захват карты** — пользователь зажимает ЛКМ или касается экрана на мобильном.
3. **Шаг 3 — перетаскивание** — Leaflet перемещает карту за курсором/пальцем, стандартное поведение.
4. **Шаг 4 — ограничение границ** — при попытке выйти за `maxBounds` вязкость 1 жёстко удерживает камеру внутри прямоугольника.
5. **Шаг 5 — отпускание** — карта останавливается в новом положении, событий кастомных обработок для движения нет.
**Критерии приёмки:**
- ✅ Панорамирование работает мышью и сенсорным вводом через стандартное поведение Leaflet.
- ✅ Движение жёстко ограничено `maxBounds` с вязкостью 1 (нельзя вытащить карту за границы).
-`attributionControl` отключён (никакой надписи Leaflet в углу).
- ✅ Маркеры и линии перемещаются вместе с подложкой без рассинхронизации.
- ✅ На мобильных устройствах drag работает одним пальцем.
---
## US-76: Информационные popups при выборе маршрута
**Цель:** После построения маршрута пользователь видит всплывающие подсказки в точках отправления и прибытия с названиями городов и кнопкой покупки билета.
**Путь клиента:**
1. **Шаг 1 — маршрут построен**`buildRoute` успешно отрисовал полилинии и вызывает `showRoutePopup(routes)`.
2. **Шаг 2 — вычисление городов** — из первого маршрута берутся коды первого и последнего элементов `route`, через `airportToCityCode` находятся объекты городов в справочнике.
3. **Шаг 3 — очистка старых popup**`clearPopup()` удаляет предыдущие `departurePopup` и `routePopup`.
4. **Шаг 4 — popup отправления** — создаётся `L.popup` с HTML `popup-header-test` и названием города отправления, привязывается к координатам маркера отправления.
5. **Шаг 5 — popup прибытия** — создаётся аналогичный popup с названием города прибытия и ссылкой `<a class="popup-buy-ticket">Купить билет</a>`, ведущей на сайт Аэрофлота.
6. **Шаг 6 — отображение** — оба popup открываются через `openOn(map)` с настройками `closeButton: true, autoClose: false, closeOnClick: false`.
**Критерии приёмки:**
- ✅ Popup появляются только после успешной отрисовки маршрута.
- ✅ Popup отправления содержит название города отправления.
- ✅ Popup прибытия содержит название города прибытия и ссылку "Купить билет".
- ✅ Ссылка "Купить билет" формируется методом `getLink()` с датой из фильтра в формате `YYYYMMDD`.
- ✅ Popup не закрываются при клике по карте (`autoClose: false`, `closeOnClick: false`).
- ✅ При смене маршрута старые popup удаляются перед созданием новых.
**Примечание:** Popup строится из содержимого первого маршрута в ответе API, даже если маршрутов несколько.
---
## US-79: Переход к покупке билета с карты
**Цель:** Пользователь может перейти со страницы карты на сайт бронирования Аэрофлота с предзаполненными параметрами маршрута и даты.
**Путь клиента:**
1. **Шаг 1 — маршрут и popup отображены** — на карте виден построенный маршрут, в popup прибытия есть ссылка "Купить билет".
2. **Шаг 2 — формирование ссылки** — метод `getLink()` собирает URL: `https://www.aeroflot.ru/sb/app/ru-ru#/search?...routes={departure}.{date}.{arrival}...`.
3. **Шаг 3 — дата и коды** — дата форматируется через `moment(...).format('YYYYMMDD')`, коды городов берутся из текущего состояния фильтра.
4. **Шаг 4 — UTM-метки** — к ссылке добавляются `utm_source=aflwebbot`, `utm_medium=referral`, `utm_campaign=ref_3015_general_rf_button.index__all_flight.map` для аналитики источника.
5. **Шаг 5 — клик по ссылке** — пользователь нажимает "Купить билет", ссылка открывается через `target="_blank"` в новой вкладке.
6. **Шаг 6 — завершение на сайте Аэрофлота** — на внешнем сайте автоматически запускается поиск (`autosearch=Y`) и выводятся результаты.
**Критерии приёмки:**
- ✅ Ссылка "Купить билет" присутствует только в popup прибытия.
- ✅ URL содержит сегмент `routes=<departure>.<YYYYMMDD>.<arrival>`.
- ✅ Если дата не выбрана, используется текущая дата (обнулённая до полуночи).
- ✅ Ссылка открывается в новой вкладке (`target="_blank"`).
- ✅ К ссылке приклеены UTM-метки источника `aflwebbot` / `ref_3015_general_rf_button.index__all_flight.map`.
**Примечание:** В приложении нет внутреннего списка рейсов карты — это намеренный deep link на основной сайт Аэрофлота для бронирования.
---
+436
View File
@@ -0,0 +1,436 @@
# Пользовательские истории: Обработка ошибок, граничные случаи и доступность
## US-85: Ошибка 404 — Страница не найдена
**Цель:** Пользователь видит понятное сообщение при переходе на несуществующий URL и может быстро вернуться к рабочему разделу.
**Путь клиента:**
1. **Переход по неверному URL — ввод или клик по битой ссылке** — пользователь вводит `/onlineboard/invalid` или попадает на устаревшую ссылку.
2. **Router сопоставляет маршрут — wildcard redirect** — роутер не находит соответствия и перенаправляет на `/error/404`.
3. **Страница ошибки загружается — ErrorPageComponent** — компонент читает `errorCode`, `title`, `description` из `route.snapshot.data`.
4. **Отображение сообщения — код и текст** — на экране виден код «404», заголовок `PAGE404.HEADER` и описание `PAGE404.DESCRIPTION`.
5. **Скрытие баннеров хоста — hideAflComponents()** — страница скрывает `.afl-component--banners` на время показа ошибки.
6. **Возврат к работе — переход на главную** — пользователь нажимает кнопку и возвращается на `/onlineboard` или в поиск.
**Критерии приёмки:**
- ✅ Маршрут `/error/404` отображает страницу с кодом 404.
- ✅ Любой неизвестный URL приводит к редиректу на `/error/404`.
- ✅ Заголовок и описание берутся из переводов (`PAGE404.*`).
- ✅ Кнопка «На главную» возвращает на рабочий раздел.
- ✅ Баннеры хост-приложения скрыты на время показа ошибки и восстанавливаются при уходе со страницы.
**Примечание:** URL онлайн-табло — `/onlineboard` (без дефиса). Компонент ошибки — общий для 404 и 500, различие задаётся через `route.data`.
---
## US-86: Ошибка 500 — Внутренняя ошибка сервера
**Цель:** Пользователь получает понятное сообщение при серверной ошибке и возможность повторить запрос или вернуться на главную.
**Путь клиента:**
1. **Действие пользователя — поиск или загрузка расписания** — пользователь запускает поиск рейсов.
2. **Ошибка на бэкенде — HTTP 5xx** — API возвращает 500 либо приложение роутится на `/error/500` как default.
3. **Показ страницы ошибки — ErrorPageComponent с errorCode=500** — читаются `PAGE500.HEADER` и `PAGE500.DESCRIPTION`.
4. **Скрытие баннеров — hideAflComponents()** — сторонние компоненты хоста временно скрываются.
5. **Действия пользователя — повтор или возврат** — пользователь может повторить запрос или перейти на главную.
6. **Восстановление — ngOnDestroy** — при уходе со страницы баннеры возвращаются в прежнее состояние.
**Критерии приёмки:**
- ✅ При неизвестной ошибке приложение показывает `/error/500` (дефолтный редирект с `/error`).
- ✅ На странице отображается код «500» и локализованные `PAGE500.*` строки.
- ✅ Кнопка «На главную» доступна и работает.
- ✅ Баннеры хоста скрываются и корректно восстанавливаются.
- ✅ Страница не ломает верстку хост-приложения.
**Примечание:** URL онлайн-табло — `/onlineboard` (без дефиса). В Angular-источнике ErrorPage один компонент на оба кода; дифференциация — через `route.data`.
---
## US-88: Timeout при загрузке данных
**Цель:** Пользователь видит корректное состояние, если ответ сервера долго не приходит, и может отменить или повторить запрос.
**Путь клиента:**
1. **Запуск запроса — пользователь выполняет поиск** — отправляется запрос на API рейсов/расписания.
2. **Отображение спиннера — loading state** — UI показывает индикатор загрузки до получения ответа.
3. **Ожидание превышает лимит — длительное отсутствие ответа** — сеть медленная или сервер не отвечает.
4. **Обработка таймаута — http-cancel / error handler** — запрос завершается с ошибкой, приложение переходит в состояние ошибки.
5. **Сообщение пользователю — понятный текст** — отображается сообщение о проблеме соединения и предложение повторить.
6. **Повтор или отмена — пользователь управляет ситуацией** — можно повторить поиск либо отказаться.
**Критерии приёмки:**
- ✅ Во время загрузки виден индикатор прогресса.
- ✅ При длительном отсутствии ответа приложение не «зависает».
- ✅ После таймаута пользователь видит понятное сообщение об ошибке.
- ✅ Доступно действие «Повторить».
- ✅ Отменённые запросы не обновляют UI задним числом.
**Примечание:** Сценарий тестовый — описывает ожидаемое поведение; в Angular-источнике явного лимита таймаута нет, обработка опирается на общий error handler и `HttpCancelService`.
---
## US-89: Некорректный ввод (специальные символы)
**Цель:** Система безопасно обрабатывает ввод со специальными символами и не допускает XSS.
**Путь клиента:**
1. **Ввод спецсимволов — пользователь печатает `<script>`, кавычки, HTML-теги** — данные попадают в поле поиска/фильтра.
2. **Санитизация — Angular/React встроенная защита** — фреймворк экранирует значения при выводе.
3. **Валидация — проверка на валидные коды IATA или названия** — некорректные значения не проходят в запрос к API.
4. **Безопасное отображение — текст как текст** — символы показываются экранированными, без исполнения.
5. **Ответ пользователю — подсказка об ошибке** — при невалидном вводе показывается сообщение.
**Критерии приёмки:**
- ✅ Ввод `<script>`-тегов не приводит к исполнению JS.
- ✅ HTML-теги отображаются как текст, а не как разметка.
- ✅ Невалидные IATA/названия отклоняются до отправки на сервер.
- ✅ Ответ сервера также экранируется при выводе.
- ✅ Атрибуты DOM не собираются из пользовательского ввода напрямую.
**Примечание:** Тестовый сценарий безопасности; в кодовой базе обеспечивается framework-уровнем (автоэкранирование).
---
## US-90: Невалидная комбинация параметров
**Цель:** Система валидирует комбинации параметров поиска и предотвращает бессмысленные запросы.
**Путь клиента:**
1. **Пользователь заполняет форму — города, даты, направление** — вводятся параметры поиска.
2. **Обнаружение конфликта — валидаторы формы** — пользователь выбирает одинаковый город для «Откуда» и «Куда», либо прошлую дату, либо недопустимый диапазон.
3. **Блокировка отправки — disable submit** — кнопка «Найти» остаётся неактивной или поиск не запускается.
4. **Отображение ошибок — подсказки под полями** — рядом с полем выводится причина ошибки.
5. **Исправление — пользователь меняет параметры** — после корректного ввода ошибки очищаются и поиск становится доступен.
**Критерии приёмки:**
- ✅ Совпадающие города «Откуда»/«Куда» отклоняются.
- ✅ Даты в прошлом недопустимы для выбора.
- ✅ Недопустимый диапазон дат показывает сообщение об ошибке.
- ✅ Пока форма невалидна, кнопка поиска неактивна.
- ✅ Сообщения об ошибках локализованы.
**Примечание:** Тестовый сценарий; в Angular-источнике правила валидации частично присутствуют в `shared/services/validators`.
---
## US-91: Пустой результат поиска
**Цель:** Пользователь получает понятное сообщение, когда по запросу нет рейсов.
**Путь клиента:**
1. **Поиск — пользователь задаёт параметры** — выбирает города и даты, запускает поиск.
2. **Запрос к API — корректные параметры** — валидация пройдена, запрос уходит на сервер.
3. **Пустой ответ — нет рейсов** — API возвращает пустой список для указанных условий.
4. **Отображение empty state — сообщение «Рейсов не найдено»** — вместо списка показывается информационный блок.
5. **Подсказки пользователю — альтернативы** — предлагается изменить дату/направление или сбросить фильтры.
**Критерии приёмки:**
- ✅ Пустой массив результатов не показывает «сломанный» UI.
- ✅ Отображается локализованное сообщение об отсутствии рейсов.
- ✅ Пользователь может легко изменить параметры поиска.
- ✅ Активные фильтры не блокируют новый поиск.
- ✅ Состояние empty отличается визуально от состояния ошибки.
---
## US-92: Unicode символы в вводе
**Цель:** Система корректно отображает и обрабатывает строки в Unicode (кириллица, CJK, арабский, emoji).
**Путь клиента:**
1. **Ввод Unicode — пользователь печатает нелатинские символы** — в поле поиска появляется текст в разных алфавитах.
2. **Передача в API — UTF-8 кодирование** — запрос корректно кодирует параметры.
3. **Ответ сервера — Unicode в названиях** — города и аэропорты возвращаются с корректной кодировкой.
4. **Отображение — шрифты и вёрстка** — названия видны без «кракозябр».
5. **Фильтрация и поиск — регистронезависимое сравнение** — совпадения находятся вне зависимости от регистра и алфавита.
**Критерии приёмки:**
- ✅ Кириллица, CJK и арабский отображаются корректно.
- ✅ Emoji не ломают верстку (допустимо отклонить при валидации IATA).
- ✅ Поиск работает с Unicode-запросами.
- ✅ Параметры корректно кодируются в URL.
- ✅ Ответ API с Unicode-строками рендерится без повреждений.
---
## US-93: Очень длинные имена городов и аэропортов
**Цель:** Интерфейс не ломается при очень длинных названиях городов, аэропортов и авиакомпаний.
**Путь клиента:**
1. **Загрузка данных — длинное название** — API возвращает запись с длинным именем (например, полное название аэропорта).
2. **Отображение в списке — ограниченная ширина** — карточка или строка имеет фиксированную ширину.
3. **Обрезка текста — CSS ellipsis** — длинный текст усекается с многоточием.
4. **Полное имя — tooltip/title** — при наведении доступно полное название.
5. **Адаптив — разные breakpoints** — поведение сохраняется на мобильных и десктопе.
**Критерии приёмки:**
- ✅ Длинные названия не выходят за границы контейнера.
- ✅ Текст усекается с видимым многоточием.
- ✅ Полное название доступно через tooltip или аналогичный механизм.
- ✅ Верстка соседних элементов не сдвигается.
- ✅ Поведение работает на всех breakpoint.
**Примечание:** Тестовый сценарий устойчивости; в кодовой базе опирается на CSS-классы текстовых ячеек.
---
## US-94: Быстрая последовательность поисков
**Цель:** Система корректно обрабатывает быстро выполняемые подряд запросы и показывает результат последнего.
**Путь клиента:**
1. **Пользователь запускает поиск — первый запрос** — уходит HTTP-запрос на сервер.
2. **Смена параметров — новый запрос до завершения предыдущего** — пользователь меняет город/дату и снова нажимает поиск.
3. **Отмена предыдущего — HttpCancelService** — незавершённые запросы отменяются через cancel-механизм.
4. **Обработка последнего — применение свежего ответа** — в UI показывается результат только последнего запроса.
5. **Защита от гонок — state не перезаписывается старым ответом** — отменённые запросы не обновляют список.
**Критерии приёмки:**
- ✅ Отменённый запрос не обновляет UI.
- ✅ В результатах отображается ответ последнего запроса.
- ✅ Индикатор загрузки виден до завершения последнего запроса.
- ✅ Быстрые клики не приводят к миганию результатов.
- ✅ Нет утечек подписок/обработчиков.
**Примечание:** В Angular-источнике есть `shared/services/http-cancel.service.ts`, обеспечивающий отмену предыдущих запросов.
---
## US-95: Клавиатурная навигация
**Цель:** Пользователь может полностью управлять приложением только с клавиатуры.
**Путь клиента:**
1. **Навигация по Tab — обход интерактивных элементов** — фокус проходит по полям, кнопкам и ссылкам в логичном порядке.
2. **Активация — Enter/Space** — кнопки и ссылки активируются клавиатурой.
3. **Отправка формы — Enter в поле поиска** — запускает поиск без использования мыши.
4. **Закрытие попапов — Escape** — выпадающие списки и диалоги закрываются по Esc.
5. **Навигация по спискам — стрелки** — в dropdown и списках работает перемещение стрелками.
6. **Видимый фокус — focus ring** — текущий элемент визуально выделен.
**Критерии приёмки:**
- ✅ Tab-порядок соответствует визуальному порядку элементов.
- ✅ Нет «ловушек» фокуса вне диалогов.
- ✅ Enter отправляет форму поиска.
- ✅ Escape закрывает выпадающие меню и диалоги.
- ✅ На всех интерактивных элементах виден focus-индикатор.
**Примечание:** Тестовый сценарий доступности; поведение собирается из встроенной семантики и ARIA.
---
## US-96: Читатели экрана (ARIA-метки)
**Цель:** Приложение корректно работает со screen reader (NVDA, VoiceOver, JAWS).
**Путь клиента:**
1. **Загрузка страницы — screen reader озвучивает заголовок**`<title>` и `h1` корректно читаются.
2. **Формы — label и aria-label** — каждое поле связано с текстовой меткой.
3. **Кнопки и иконки — aria-label для icon-only** — иконочные кнопки имеют текстовую аннотацию.
4. **Динамический контент — aria-live** — появление результатов поиска или ошибок объявляется.
5. **Списки и таблицы — корректные роли** — расписание использует семантическую структуру.
6. **Изображения — alt** — декоративные скрыты (`alt=""`), смысловые имеют описание.
**Критерии приёмки:**
- ✅ Все поля форм имеют связанный label или aria-label.
- ✅ Icon-only кнопки озвучиваются со смысловой подписью.
- ✅ Динамические обновления (результаты, ошибки) объявляются screen reader.
- ✅ Используется семантический HTML (`button`, `nav`, `main`, `h1``h3`).
- ✅ Изображения имеют корректный alt.
**Примечание:** Тестовый сценарий WCAG; конкретная реализация ARIA — часть компонентного слоя.
---
## US-98: Фокусное кольцо (focus-visible)
**Цель:** Пользователь, навигирующий с клавиатуры, всегда видит, какой элемент находится в фокусе.
**Путь клиента:**
1. **Навигация Tab — пользователь переходит между элементами** — каждое нажатие Tab смещает фокус.
2. **Рендеринг focus ring — CSS `:focus-visible`** — обводка появляется только при клавиатурной навигации.
3. **Скрытие при клике мышью — `:focus:not(:focus-visible)`** — кольцо не отображается при обычных кликах.
4. **Контрастность — видимое кольцо** — цвет и толщина заметны на фоне.
5. **Единообразие — стиль одинаков для всех интерактивных элементов** — кнопки, ссылки, поля ввода.
**Критерии приёмки:**
- ✅ При Tab-навигации фокус виден на всех интерактивных элементах.
- ✅ При кликах мышью кольцо не появляется (используется `:focus-visible`).
- ✅ Контраст кольца соответствует WCAG AA.
- ✅ Стиль фокуса согласован между компонентами.
- ✅ Ни один интерактивный элемент не остаётся без индикатора фокуса.
**Примечание:** Тестовый сценарий доступности, реализуется через глобальные CSS-стили.
---
## US-99: Масштабирование текста (zoom)
**Цель:** Пользователь может увеличивать текст/страницу до 200% без потери функциональности.
**Путь клиента:**
1. **Запуск zoom — Ctrl + / Cmd +** — браузер увеличивает масштаб страницы.
2. **Адаптация текста — относительные единицы**`rem`/`em` корректно пересчитываются.
3. **Адаптивный layout — reflow** — сетка перестраивается под новую ширину.
4. **Отсутствие горизонтального скролла — контент остаётся в viewport** — до 200% без обрезки.
5. **Интерактив доступен — все кнопки и поля видны** — форма поиска и результаты пригодны к использованию.
**Критерии приёмки:**
- ✅ Увеличение до 200% не ломает layout.
- ✅ Нет горизонтального скролла на типовых breakpoint.
- ✅ Текст не перекрывается и не обрезается.
- ✅ Все интерактивные элементы остаются доступными.
- ✅ Relative units используются для размеров текста и отступов.
**Примечание:** Тестовый сценарий WCAG 1.4.4; реализация зависит от CSS.
---
## US-100: Сенсорная навигация (touch)
**Цель:** Мобильные пользователи могут полностью управлять приложением пальцами.
**Путь клиента:**
1. **Touch-жесты — tap, swipe, pinch** — пользователь взаимодействует с экраном.
2. **Размер целей — минимум 44×44px** — кнопки и ссылки достаточны для попадания пальцем.
3. **Интервалы — достаточные отступы** — соседние цели не кликаются случайно.
4. **Альтернативы жестам — обычные кнопки** — любое действие через swipe дублируется кнопкой.
5. **Отсутствие hover-only — tap доступен всегда** — функциональность не скрыта за hover.
**Критерии приёмки:**
- ✅ Touch-таргеты не меньше 44×44px.
- ✅ Расстояние между целями предотвращает случайные нажатия.
- ✅ Все функции доступны без hover.
- ✅ Жесты (где используются) имеют альтернативу обычным тапом.
- ✅ Скролл и pinch-zoom работают штатно.
**Примечание:** Тестовый сценарий мобильной доступности.
---
## US-101: Персистентное состояние (StateService)
**Цель:** Ключевое состояние приложения сохраняется между переходами и переживает перезагрузку страницы там, где это предусмотрено.
**Путь клиента:**
1. **Сохранение — StateService.set(key, data)** — страница кладёт состояние в Map по ключу.
2. **Переход между разделами — навигация** — пользователь уходит с экрана и возвращается.
3. **Чтение — StateService.get(key)** — экран восстанавливает данные из сервиса.
4. **Параметры поиска в URL — query params** — часть состояния синхронизируется через URL.
5. **Перезагрузка страницы — refresh** — параметры из URL позволяют восстановить форму и запустить поиск.
6. **Очистка — StateService.delete(key)** — при необходимости состояние сбрасывается.
**Критерии приёмки:**
-`StateService.set`/`get`/`delete` работают как in-memory Map хранилище.
- ✅ При возврате на страницу её состояние восстанавливается из сервиса.
- ✅ Параметры поиска присутствуют в URL и читаются при старте.
- ✅ После refresh форма заполняется из URL-параметров.
- ✅ Данные не «текут» между независимыми ключами.
**Примечание:** В Angular-источнике `StateService` — простой in-memory Map (`shared/services/state.service.ts`), он не использует localStorage; персистенция между перезагрузками достигается через URL.
---
## US-102: История браузера (back/forward)
**Цель:** Кнопки браузера «Назад» и «Вперёд» корректно перемещают пользователя по состояниям приложения.
**Путь клиента:**
1. **Переход по разделам — пользователь открывает страницы** — роутер добавляет записи в history.
2. **Обновление URL — query params отражают фильтры** — параметры поиска и сортировки видны в URL.
3. **Нажатие Back — popstate** — роутер восстанавливает предыдущее состояние.
4. **Нажатие Forward — возврат к следующему состоянию** — аналогично, без потери данных.
5. **Синхронизация UI — чтение query params** — фильтры и форма обновляются под URL.
**Критерии приёмки:**
- ✅ Каждая значимая навигация создаёт запись в history.
- ✅ Back возвращает на предыдущий URL с корректным состоянием.
- ✅ Forward работает симметрично.
- ✅ Query params читаются при навигации и обновляют UI.
- ✅ Глубокие ссылки открывают нужный раздел напрямую.
**Примечание:** В Angular-источнике навигация — стандартный Angular Router; синхронизация через query params.
---
## US-103: Обработка больших объёмов данных
**Цель:** Приложение остаётся отзывчивым при отображении длинных списков рейсов/расписания.
**Путь клиента:**
1. **Загрузка данных — большой ответ API** — приходит расписание с сотнями рейсов.
2. **Рендер списка — оптимизированный вывод** — используется виртуализация либо разумная пагинация/ограничение.
3. **Скролл — плавная прокрутка** — браузер не проседает по FPS.
4. **Фильтрация и сортировка — мгновенная реакция** — действия не вызывают полной перерисовки.
5. **Освобождение памяти — unsubscribe при уходе** — подписки и кэши корректно очищаются.
**Критерии приёмки:**
- ✅ Список из сотен элементов рендерится без ощутимых задержек.
- ✅ Скролл остаётся плавным на типовых устройствах.
- ✅ Фильтрация и сортировка не «замораживают» UI.
- ✅ При уходе со страницы не остаётся зомби-подписок.
- ✅ Память приложения не растёт неограниченно при повторных поисках.
**Примечание:** Тестовый сценарий производительности; конкретный механизм (виртуализация/пагинация) — на выбор реализации.
---
## US-104: Кэширование ответов (CacheService 30s)
**Цель:** Повторные одинаковые запросы к API обслуживаются из кэша, чтобы снизить нагрузку и ускорить отклик.
**Путь клиента:**
1. **Первый запрос — CacheService.get(key, options)** — кэш пуст, запрос уходит на сервер.
2. **Сохранение ответа — CacheService.set(key, options, result)** — результат кладётся в `requestsMap` по ключу.
3. **Таймер инвалидации — setTimeout 30s** — через `INVALID_TIMEOUT = 30 * 1000` запись удаляется.
4. **Повторный запрос в пределах окна — быстрый ответ**`get()` возвращает закэшированный `result`.
5. **Истечение 30 секунд — удаление ключа** — следующий запрос снова идёт на сервер.
6. **Ручная инвалидация — CacheService.delete(key)** — позволяет сбросить запись принудительно.
**Критерии приёмки:**
-`CacheService.set/get/delete` работают по in-memory Map.
- ✅ Таймаут инвалидации — 30 секунд (`INVALID_TIMEOUT = 30 * 1000`).
- ✅ Повторный запрос в пределах окна не порождает сетевой вызов.
- ✅ После таймаута запись удаляется и следующий запрос уходит на сервер.
-`delete(key)` очищает указанную запись вручную.
**Примечание:** Источник истины — `shared/services/cache.service.ts`. Таймер на каждую `set()` перезапускается (`clearTimeout`), поэтому окно отсчитывается от последнего обновления записи.
---
+3 -9
View File
@@ -12,23 +12,17 @@ export default defineConfig({
runtime: {
router: true,
},
dev: {
proxy: {
"/api": "https://flights.test.aeroflot.ru/api",
"/flights": "https://flights.test.aeroflot.ru/flights",
},
},
server: {
ssr: {
mode: "stream",
},
},
output: {
distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
},
tools: {
cssLoader: {
url: false,
},
},
output: {
distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
},
});
+4 -1
View File
@@ -21,7 +21,9 @@
"typecheck": "tsc --noEmit",
"bundle-size": "node scripts/ci/bundle-size-gate.mjs",
"check-coverage": "node scripts/ci/check-coverage-delta.mjs",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"proxy": "node scripts/api-proxy.mjs",
"dev:full": "node scripts/api-proxy.mjs & pnpm dev"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
@@ -67,6 +69,7 @@
"eslint-plugin-boundaries": "^5.0.0",
"eslint-plugin-unused-imports": "^4.0.0",
"fast-check": "^4.6.0",
"http-proxy-middleware": "^3.0.5",
"jsdom": "^29.0.2",
"react-test-renderer": "^19.2.5",
"sass": "^1.99.0",
+52 -2
View File
@@ -132,6 +132,9 @@ importers:
fast-check:
specifier: ^4.6.0
version: 4.6.0
http-proxy-middleware:
specifier: ^3.0.5
version: 3.0.5
jsdom:
specifier: ^29.0.2
version: 29.0.2
@@ -2730,6 +2733,9 @@ packages:
'@types/html-minifier-terser@6.1.0':
resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}
'@types/http-proxy@1.17.17':
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -3912,6 +3918,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -4244,6 +4253,14 @@ packages:
resolution: {integrity: sha512-Yy9VFT/0fJhbpSHmqA34CJKZDXLnHoQUP2wbFXY7duOx3nc9Qf8MVJezaXTP7IirvJ9DmUv/vm7qFNu/RntdWw==}
engines: {node: '>= 4'}
http-proxy-middleware@3.0.5:
resolution: {integrity: sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
http-proxy@1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
https-browserify@1.0.0:
resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
@@ -4366,6 +4383,10 @@ packages:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -9785,6 +9806,10 @@ snapshots:
'@types/html-minifier-terser@6.1.0': {}
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 24.12.2
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -10264,7 +10289,7 @@ snapshots:
axios@1.15.0:
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -11238,6 +11263,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
events@3.3.0: {}
eventsource@2.0.2: {}
@@ -11343,7 +11370,9 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3(supports-color@5.5.0)
for-each@0.3.5:
dependencies:
@@ -11597,6 +11626,25 @@ snapshots:
http-compression@1.0.6: {}
http-proxy-middleware@3.0.5:
dependencies:
'@types/http-proxy': 1.17.17
debug: 4.4.3(supports-color@5.5.0)
http-proxy: 1.18.1(debug@4.4.3)
is-glob: 4.0.3
is-plain-object: 5.0.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
http-proxy@1.18.1(debug@4.4.3):
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.0(debug@4.4.3)
requires-port: 1.0.0
transitivePeerDependencies:
- debug
https-browserify@1.0.0: {}
https-proxy-agent@7.0.6:
@@ -11717,6 +11765,8 @@ snapshots:
dependencies:
isobject: 3.0.1
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@2.2.2: {}
+107
View File
@@ -0,0 +1,107 @@
/**
* API proxy for development — forwards /api/* to flights.test.aeroflot.ru
* through the system HTTPS proxy (GOST at 127.0.0.1:8888).
*
* Run: node scripts/api-proxy.mjs
* Listens on port 4201.
*/
import http from "node:http";
import https from "node:https";
import { URL } from "node:url";
const TARGET = "flights.test.aeroflot.ru";
const PORT = 4201;
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
function proxyViaConnect(req, res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (SYSTEM_PROXY) {
// Use HTTP CONNECT tunnel through system proxy
const proxyUrl = new URL(SYSTEM_PROXY);
const connectReq = http.request({
host: proxyUrl.hostname,
port: parseInt(proxyUrl.port || "8888"),
method: "CONNECT",
path: `${TARGET}:443`,
});
connectReq.on("connect", (_connectRes, socket) => {
const options = {
hostname: TARGET,
path: req.url,
method: req.method,
headers: {
host: TARGET,
accept: "application/json, text/plain, */*",
"accept-language": "ru",
},
socket,
agent: false,
};
const targetReq = https.request(options, (targetRes) => {
const headers = { ...targetRes.headers, "access-control-allow-origin": "*" };
delete headers["transfer-encoding"];
res.writeHead(targetRes.statusCode ?? 200, headers);
targetRes.pipe(res, { end: true });
});
targetReq.on("error", (err) => {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
});
req.pipe(targetReq, { end: true });
});
connectReq.on("error", (err) => {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Proxy connect error: ${err.message}` }));
});
connectReq.end();
} else {
// Direct HTTPS without system proxy
const options = {
hostname: TARGET,
port: 443,
path: req.url,
method: req.method,
headers: {
host: TARGET,
accept: "application/json, text/plain, */*",
"accept-language": "ru",
},
};
const targetReq = https.request(options, (targetRes) => {
res.writeHead(targetRes.statusCode ?? 200, {
...targetRes.headers,
"access-control-allow-origin": "*",
});
targetRes.pipe(res, { end: true });
});
targetReq.on("error", (err) => {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
});
req.pipe(targetReq, { end: true });
}
}
const server = http.createServer(proxyViaConnect);
server.listen(PORT, () => {
console.log(`API proxy on http://localhost:${PORT} → https://${TARGET}`);
if (SYSTEM_PROXY) console.log(`Using system proxy: ${SYSTEM_PROXY}`);
});
+2 -2
View File
@@ -9,8 +9,8 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "testing", "staging", "production"]).default("development"),
BUILD_TARGET: z.enum(["standalone", "remote"]).default("standalone"),
PROD_ORIGIN: z.string().url().default("http://localhost:8080"),
API_BASE_URL: z.string().url().default("http://localhost:8080/api"),
SIGNALR_HUB_URL: z.string().url().default("ws://localhost:8080/hub"),
API_BASE_URL: z.string().url().default("http://localhost:4200/api"),
SIGNALR_HUB_URL: z.string().url().default("http://platform.yc.webzavod.ru/tracker/hub"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
LOGS_ENDPOINT: z.string().url().optional(),
@@ -36,16 +36,31 @@ export interface OnlineBoardSearchPageProps {
params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
}
/**
* Convert yyyyMMdd URL date to API format (yyyy-MM-ddT00:00:00).
*/
function formatDateForApi(yyyymmdd: string): string {
if (yyyymmdd.length === 8) {
const y = yyyymmdd.slice(0, 4);
const m = yyyymmdd.slice(4, 6);
const d = yyyymmdd.slice(6, 8);
return `${y}-${m}-${d}T00:00:00`;
}
// Already in ISO-ish format
return yyyymmdd.includes("T") ? yyyymmdd : `${yyyymmdd}T00:00:00`;
}
/**
* Convert parsed online board URL params into API search params.
* The API expects dateFrom/dateTo (same day for single-date searches).
* The API expects dateFrom/dateTo in yyyy-MM-ddT00:00:00 format.
*/
function toSearchParams(
params: OnlineBoardSearchPageProps["params"],
): SearchFlightsParams {
const apiDate = formatDateForApi(params.date);
const base: SearchFlightsParams = {
dateFrom: params.date,
dateTo: params.date,
dateFrom: apiDate,
dateTo: apiDate,
};
switch (params.type) {
+7 -7
View File
@@ -5,6 +5,7 @@ import { LoggerProvider } from "@/observability/logger/provider";
import { createRootLogger } from "@/observability/logger/root";
import { ApiClientProvider } from "@/shared/api/provider";
import { ApiClient } from "@/shared/api/client";
import { getEnv } from "@/env/index";
// Global styles
import "@/styles/index.scss";
@@ -21,15 +22,14 @@ export default function RootLayout(): JSX.Element {
const logger = useMemo(() => createRootLogger(), []);
const apiClient = useMemo(
() =>
new ApiClient({
baseUrl:
typeof process !== "undefined"
? process.env["API_BASE_URL"] ?? "/api"
: "/api",
() => {
const env = getEnv();
return new ApiClient({
baseUrl: env.API_BASE_URL,
locale: "ru",
logger,
}),
});
},
[logger],
);
+11 -5
View File
@@ -1,15 +1,21 @@
import { redirect } from "@modern-js/runtime/router";
import { useEffect } from "react";
import { redirect, useNavigate } from "@modern-js/runtime/router";
const DEFAULT_LANG = "ru";
/**
* Root `/` route -- redirects to `/{defaultLang}/onlineboard` to match
* the Angular app's default routing behavior (see `app-routing.module.ts`).
* Root `/` route redirects to `/{defaultLang}/onlineboard` to match
* the Angular app's default routing behavior.
*/
export const loader = () => redirect(`/${DEFAULT_LANG}/onlineboard`);
export default function Home() {
// The loader redirect fires before render, so this component body
// should never be reached. Keep it as a no-op fallback.
const navigate = useNavigate();
// Client-side fallback redirect in case the loader redirect doesn't fire
useEffect(() => {
void navigate(`/${DEFAULT_LANG}/onlineboard`, { replace: true });
}, [navigate]);
return null;
}