71d0c983fd
Root cause of search not working: globalThis.fetch stored as a class field loses its Window binding, causing 'Illegal invocation'. Fixed with fetch.bind(globalThis). Also fix calendar days endpoint date format from yyyyMMdd to yyyy-MM-ddT00:00:00 matching Angular's ApiFormatterService.
273 lines
23 KiB
Markdown
273 lines
23 KiB
Markdown
# Пользовательские истории: Поиск расписания полётов
|
||
|
||
**Важное примечание:** Функции этого раздела (обратный рейс, фильтры по времени, прямые рейсы) **специфичны для раздела "Расписание"** и не дублируют функции Онлайн-Табло.
|
||
|
||
---
|
||
|
||
## 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` запрашивает справочник и показывает выпадающий список совпадений. В компоненте `city-select`, если у города несколько аэропортов, каждый аэропорт сопровождается `pTooltip` с полным названием (`airport.name`, позиция `top`).
|
||
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`.
|
||
|
||
---
|