Files
flights_web/docs/user-stories-3-schedule-search.md
gnezim 71d0c983fd
CI / ci (push) Failing after 28s
Deploy / build-and-deploy (push) Failing after 5s
Fix API calls: bind fetch to globalThis, fix date format for calendar
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.
2026-04-15 22:32:51 +03:00

273 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Пользовательские истории: Поиск расписания полётов
**Важное примечание:** Функции этого раздела (обратный рейс, фильтры по времени, прямые рейсы) **специфичны для раздела "Расписание"** и не дублируют функции Онлайн-Табло.
---
## 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`.
---