Fix API integration: proxy via Angular, date format, root redirect
- 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:
@@ -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** | **Кодовая верификация: удалено всё неподтверждённое** |
|
||||
@@ -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 и необработанных промисах.
|
||||
@@ -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` прерывает активный запрос.
|
||||
- ✅ После отмены пользователь остаётся на странице с предыдущим результатом или пустым состоянием.
|
||||
- ✅ После завершения загрузки все индикаторы скрываются.
|
||||
- ✅ Повторный поиск корректно перезапускает цикл загрузки.
|
||||
|
||||
---
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
@@ -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*`-флаги.
|
||||
|
||||
---
|
||||
@@ -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 3–6).
|
||||
- ✅ Прямые маршруты отображаются сплошной линией синего цвета (`#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 на основной сайт Аэрофлота для бронирования.
|
||||
|
||||
---
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+52
-2
@@ -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: {}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
Vendored
+2
-2
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user