# Пользовательские истории: Поиск расписания полётов **Важное примечание:** Функции этого раздела (обратный рейс, фильтры по времени, прямые рейсы) **специфичны для раздела "Расписание"** и не дублируют функции Онлайн-Табло. --- ## 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`. ---