From 71d0c983fd3b0d0ad732795faa75314447098365 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 22:32:51 +0300 Subject: [PATCH] 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. --- docs/USER_STORIES_INDEX.md | 56 +++++----- docs/user-stories-1-navigation-ui.md | 10 +- docs/user-stories-2-online-board.md | 26 +++-- docs/user-stories-3-schedule-search.md | 2 +- docs/user-stories-4-schedule-results.md | 4 +- docs/user-stories-5-flight-details.md | 25 +++-- docs/user-stories-6-flights-map.md | 7 +- docs/user-stories-7-errors-accessibility.md | 100 +++++++++++++++++- src/env/index.ts | 2 + .../components/OnlineBoardSearchPage.tsx | 2 +- .../online-board/hooks/useOnlineBoard.ts | 8 +- src/shared/api/client.ts | 2 +- 12 files changed, 187 insertions(+), 57 deletions(-) diff --git a/docs/USER_STORIES_INDEX.md b/docs/USER_STORIES_INDEX.md index 0ddbd66c..5de33f14 100644 --- a/docs/USER_STORIES_INDEX.md +++ b/docs/USER_STORIES_INDEX.md @@ -2,9 +2,9 @@ ## ✅ ВЕРИФИЦИРОВАНО: Только реально реализованные функции Angular -**Дата последнего обновления:** 9 апреля 2026 GMT+3 -**Статус:** Очищено - удалены все нереализованные/гипотетические функции -**Источник верификации:** `/Users/gnezim/_projects/tims/flights_web/Aeroflot.Flights.Web/ClientApp` (Angular 12.2.13) +**Дата последнего обновления:** 15 апреля 2026 GMT+3 +**Статус:** Верифицировано v4.1 — полный аудит Angular-кода, добавлены пропущенные функции +**Источник верификации:** `ClientApp/` (Angular 12.2.13) **Итоги аудита (Версия 4.0):** @@ -42,7 +42,7 @@ Это набор пользовательских историй для **UI Dashboard** — React-версии онлайн доски полётов Аэрофлота. Документы организованы по функциональным областям и содержат описание целей, путей клиента и критериев приёмки. -**Всего историй:** 85 пользовательских историй ✅ +**Всего историй:** 89 пользовательских историй ✅ **Документов:** 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) @@ -162,7 +162,7 @@ ### 📄 [Документ 7: Обработка ошибок и доступность](user-stories-7-errors-accessibility.md) -**US-85 до US-104 (без US-87, 97)** — 18 историй +**US-85 до US-108 (без US-87, 97)** — 22 истории **Ошибки:** @@ -194,20 +194,27 @@ - US-103: Обработка больших объёмов - US-104: Кэширование (CacheService, 30s) +**Real-time и инфраструктура (добавлено в v4.1):** + +- US-105: Обновление данных в реальном времени (SignalR + FadeService) +- US-106: Встроенный чат-бот +- US-107: Canonical URL и SEO мета-теги по страницам +- US-108: Отображение версии API (debug-режим) + --- ## Статистика -| Документ | Диапазон | Историй | Статус | -| ------------------------ | ----------------------- | ------- | --------------------------- | -| 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** | ✅ Полностью верифицировано | +| Документ | Диапазон | Историй | Статус | +| ------------------------ | -------------------------------- | ------- | --------------------------- | +| 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, 105-108 | 22 | ✅ Все реализованы | +| **ИТОГО** | **89 историй** | **89** | ✅ Полностью верифицировано | --- @@ -241,14 +248,14 @@ Каждая оставшаяся история подтверждена в исходном коде по следующим путям: -- `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 +- `ClientApp/src/app/features/online-board/` — Doc 2 +- `ClientApp/src/app/features/schedule/` — Docs 3, 4 +- `ClientApp/src/app/features/flights-map/` — Doc 6 +- `ClientApp/src/app/features/popular-requests/` — Doc 1 US-7 +- `ClientApp/src/app/modules/pages/details/` — Doc 5 +- `ClientApp/src/app/components/` (city-autocomplete, dates-selectors, breadcrumds, search-history, page) — Doc 1, общие +- `ClientApp/src/app/shared/services/` (localization, state, cache) — Doc 1, Doc 7 +- `ClientApp/src/app/modules/pages/error-pages/` — Doc 7 --- @@ -260,4 +267,5 @@ | 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** | **Кодовая верификация: удалено всё неподтверждённое** | +| 4.0 | Apr 9 2026 | 85 | 7 | Кодовая верификация: удалено всё неподтверждённое | +| **4.1** | **Apr 15 2026**| **89** | **7** | **Повторная верификация: +4 истории (SignalR, чат-бот, SEO, debug), обновлены тултипы, календарь, автодополнение, код-шеринг** | diff --git a/docs/user-stories-1-navigation-ui.md b/docs/user-stories-1-navigation-ui.md index 48676213..558c1201 100644 --- a/docs/user-stories-1-navigation-ui.md +++ b/docs/user-stories-1-navigation-ui.md @@ -176,8 +176,9 @@ 1. **Отображение секции** — компонент `search-history` рендерит `
`, но только если `historyItems.length > 0`. 2. **Аккордеон** — внутри секции расположен `` с одним ``, заголовок которого содержит ключ `BOARD.YOU_SEARCH` и `arrow-down-icon`. 3. **Список элементов** — в содержимом таба через `*ngFor` выводятся `` для каждой записи из `historyItems`. -4. **Клик по элементу** — обработчик `(click)="openHistoryUrl(item)"` выполняет переход по сохранённому URL запроса, восстанавливая параметры формы. -5. **Типы элементов** — поддерживаются разные шаблоны записей: `online-board-flight-number-history-item`, `online-board-route-history-item`, `schedule-item` — каждый отрисовывает свой набор полей. +4. **Иконка с тултипом типа поиска** — каждая запись начинается с иконки: для онлайн-табло (`plane-icon`) с `pTooltip` `SHARED.LAST-SEARCH-BOARD`, для расписания (`alarm-clock-icon`) с `pTooltip` `SHARED.LAST-SEARCH-SCHEDULE` (позиция `top`, стиль `afl-tooltip`). +5. **Клик по элементу** — обработчик `(click)="openHistoryUrl(item)"` выполняет переход по сохранённому URL запроса, восстанавливая параметры формы. +6. **Типы элементов** — поддерживаются разные шаблоны записей: `online-board-flight-number-history-item`, `online-board-route-history-item`, `schedule-item` — каждый отрисовывает свой набор полей. 6. **Отсутствие на карте полётов** — `search-history` не включён в шаблон страницы `/flights-map`, поэтому там секция не показывается. **Критерии приёмки:** @@ -185,6 +186,7 @@ - ✅ Секция видна только при наличии хотя бы одной записи. - ✅ Заголовок таба переведён через ключ `BOARD.YOU_SEARCH`. - ✅ Каждая запись — отдельный `` с собственным шаблоном. +- ✅ Иконка типа записи сопровождается PrimeNG-тултипом (`SHARED.LAST-SEARCH-BOARD` или `SHARED.LAST-SEARCH-SCHEDULE`). - ✅ Клик по записи вызывает `openHistoryUrl(item)` и выполняет переход. - ✅ На странице «Карта полётов» секция отсутствует. @@ -199,7 +201,8 @@ 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. **Применение языка** — значения подставляются в ``, `<meta name="description">` и OpenGraph-теги на языке, соответствующем `baseHref`. +4. **Тултип заголовка страницы** — компонент `page-title` дублирует текст заголовка в `pTooltip` (позиция `bottom`, `hideDelay: 1000`, стиль `afl-tooltip tooltip--one-line`), чтобы длинные заголовки, усечённые CSS, были полностью доступны при наведении. +5. **Применение языка** — значения подставляются в `<title>`, `<meta name="description">` и OpenGraph-теги на языке, соответствующем `baseHref`. 5. **Смена языка через URL** — при переходе на префикс другого языка приложение перезагружается, резолвер и компоненты формируют мета-информацию уже на новом языке. **Критерии приёмки:** @@ -208,6 +211,7 @@ - ✅ Мета-теги `description`/`keywords` формируются из ключей перевода. - ✅ Смена языка через URL-префикс обновляет title и meta. - ✅ Все три раздела (`onlineboard`, `schedule`, `flights-map`) имеют собственные ключи заголовков. +- ✅ Заголовок `<h1>` сопровождается `pTooltip` (bottom, hideDelay 1000) для доступности длинных усечённых заголовков. - ✅ При отсутствии перевода подставляется fallback-значение ключа. --- diff --git a/docs/user-stories-2-online-board.md b/docs/user-stories-2-online-board.md index 17260e93..a7158f86 100644 --- a/docs/user-stories-2-online-board.md +++ b/docs/user-stories-2-online-board.md @@ -8,10 +8,11 @@ 1. **Открытие фильтра** — пользователь открывает аккордеон «Номер рейса» в панели фильтров онлайн-табло; активная вкладка выделяется цветом. 2. **Ввод номера** — в составном поле слева зафиксирован префикс «SU», справа — текстовое поле с плейсхолдером (пример «1234»), максимальная длина 5 символов. -3. **Валидация формата** — сервис проверяет регулярное выражение `^\d\d\d\d?[A-Za-z]?$`: 3–4 цифры и опциональный буквенный суффикс; при ошибке показывается тултип с сообщением. +3. **Валидация формата** — сервис проверяет регулярное выражение `^\d\d\d\d?[A-Za-z]?$`: 3–4 цифры и опциональный буквенный суффикс; при ошибке показывается кастомный компонент `<tooltip>` с локализованным сообщением (не PrimeNG `pTooltip`, а отдельный всплывающий блок ошибки). 4. **Выбор даты** — ниже расположен `calendar-input` с меткой «Дата вылета»; диапазон ограничен `boardMinDate`/`boardMaxDate`, недоступные даты заблокированы. -5. **Запуск поиска** — кнопка «Найти» активна; при успешной валидации вызывается `handleFlightNumberSearch`, происходит переход на страницу результатов. -6. **Очистка поля** — кнопка-крестик справа от ввода очищает номер одним кликом. +5. **Автодополнение номера** — при потере фокуса метод `addZeros()` дополняет номер нулями до 4 цифр и отделяет буквенный суффикс (например, ввод `14` → `0014`, `1402A` → `1402` + суффикс `A`). +6. **Запуск поиска** — кнопка «Найти» активна; при успешной валидации вызывается `handleFlightNumberSearch`, происходит переход на страницу результатов. +7. **Очистка поля** — кнопка-крестик справа от ввода очищает номер одним кликом. **Критерии приёмки:** @@ -20,6 +21,7 @@ - ✅ Пустой номер показывает ошибку `FLIGHT_NUMBER-ERROR-EMPTY`. - ✅ Некорректный формат показывает ошибку `FLIGHT_NUMBER-ERROR-ONLY-NUMBER`. - ✅ Поиск не запускается, если не выбрана валидная дата. +- ✅ При потере фокуса `addZeros()` дополняет номер до 4 цифр и выделяет буквенный суффикс. - ✅ Кнопка очистки сбрасывает значение номера рейса. --- @@ -33,8 +35,10 @@ 1. **Открытие календаря** — пользователь кликает по `calendar-input` с меткой «Дата вылета»; открывается поповер с месячной сеткой. 2. **Ограничение диапазона** — календарь принимает `minDate = boardMinDate` (вчера, −1 день) и `maxDate = boardMaxDate` (по умолчанию +7 дней от сегодня, настраивается через серверный контракт `boardSearchTo`). 3. **Блокировка недоступных дат** — массив `disabledDates` затемняет даты, по которым нет расписания, клик по ним игнорируется. -4. **Выбор даты** — при клике на активную дату значение записывается в модель фильтра, поповер закрывается. -5. **Отображение ошибки** — некорректное значение (пустое или невалидное) показывает сообщение `SHARED.DATE_FORMAT-WRONG` под полем. +4. **Быстрые кнопки** — под календарной сеткой отображаются кнопки «Сегодня» и «Завтра» (локализованные), позволяющие выбрать дату одним кликом без навигации по месяцам. +5. **Выбор даты** — при клике на активную дату значение записывается в модель фильтра, поповер закрывается. +6. **Автообновление диапазона в полночь** — таймер отслеживает смену суток и автоматически сдвигает `boardMinDate`/`boardMaxDate` без перезагрузки страницы. +7. **Отображение ошибки** — некорректное значение (пустое или невалидное) показывает сообщение `SHARED.DATE_FORMAT-WRONG` под полем. 6. **Синхронизация с результатами** — выбранная дата передаётся в страницу поиска и синхронизируется с компонентом `day-tabs`. **Критерии приёмки:** @@ -42,6 +46,8 @@ - ✅ Недоступны даты раньше `boardMinDate` и позже `boardMaxDate`. - ✅ Даты из `disabledDates` визуально выключены и не кликабельны. - ✅ Невалидная дата вызывает показ сообщения об ошибке. +- ✅ Быстрые кнопки «Сегодня»/«Завтра» позволяют выбрать дату одним кликом. +- ✅ При смене суток min/max даты автоматически сдвигаются через таймер. - ✅ Выбранная дата сохраняется в модели и передаётся в URL при поиске. - ✅ Календарь закрывается после выбора даты. @@ -54,8 +60,8 @@ **Путь клиента:** 1. **Открытие аккордеона «Маршрут»** — раскрывается вкладка с формой маршрутного поиска внутри `online-board-filter`. -2. **Ввод города отправления** — компонент `city-autocomplete` с меткой «Город отправления» принимает ввод и показывает выпадающий список подсказок. -3. **Выбор из подсказок** — по мере набора сервис `cities-search-service` возвращает совпадения; клик по элементу подставляет город в поле. +2. **Ввод города отправления** — компонент `city-autocomplete` с меткой «Город отправления» принимает ввод и показывает выпадающий список подсказок. Поддерживается конвертация раскладки клавиатуры (`ruKeyboardLayout`): пользователь может набирать латиницей при включённой русской раскладке, и наоборот. +3. **Выбор из подсказок** — по мере набора сервис `cities-search-service` возвращает совпадения; клик по элементу подставляет город в поле. В выпадающем списке `city-select`, если у города несколько аэропортов, каждый аэропорт сопровождается `pTooltip` с полным названием аэропорта (`airport.name`, позиция `top`). 4. **Оставление поля прибытия пустым** — для поиска только по отправлению пользователь не заполняет второе поле. 5. **Выбор даты и запуск поиска** — указывается дата в `calendar-input`, клик по «Найти» переходит на страницу результатов вылетов. 6. **Обработка ошибки** — если город не выбран из списка, показывается ошибка `departureError` под полем. @@ -63,7 +69,10 @@ **Критерии приёмки:** - ✅ Автодополнение работает при вводе минимум нескольких символов. +- ✅ Поддерживается конвертация раскладки клавиатуры (русская ↔ латиница). +- ✅ При точном совпадении ввода с кодом или названием города происходит авто-выбор. - ✅ Принимается только город, выбранный из списка подсказок. +- ✅ При наличии нескольких аэропортов у города каждый показывает `pTooltip` с полным названием. - ✅ Ошибка отображается, если город введён, но не распознан. - ✅ Поиск возможен только с заполненным полем отправления и валидной датой. - ✅ Результаты содержат только рейсы с вылетом из указанного города. @@ -104,7 +113,8 @@ 3. **Кнопка обмена** — между полями расположена кнопка со SVG-иконкой `changeCity`; клик вызывает метод `exchange()` и меняет значения полей местами. 4. **Выбор даты** — используется `calendar-input` с ограничениями `boardMinDate`/`boardMaxDate`. 5. **Опциональный фильтр времени** — под датой компонент `time-selector` в режиме `fullView=false` позволяет ограничить диапазон времени вылета. -6. **Запуск поиска** — кнопка «Найти» вызывает `handleRouteSearch`, результаты открываются на маршрутной странице. +6. **Автозаполнение по геолокации** — если `UserLocationService` определил местоположение пользователя и поле отправления пусто, оно предзаполняется кодом ближайшей станции. +7. **Запуск поиска** — кнопка «Найти» вызывает `handleRouteSearch`, результаты открываются на маршрутной странице. **Критерии приёмки:** diff --git a/docs/user-stories-3-schedule-search.md b/docs/user-stories-3-schedule-search.md index 60328b83..b5e38203 100644 --- a/docs/user-stories-3-schedule-search.md +++ b/docs/user-stories-3-schedule-search.md @@ -37,7 +37,7 @@ 1. **Фокус на поле** — пользователь кликает в поле "Город отправления" (`city-autocomplete`, `data-testid="schedule-departure-city-input"`). 2. **Ввод текста** — пользователь вводит название города или IATA-код (например, "Мос" или "SVO"). -3. **Автодополнение** — компонент `city-autocomplete` запрашивает справочник и показывает выпадающий список совпадений. +3. **Автодополнение** — компонент `city-autocomplete` запрашивает справочник и показывает выпадающий список совпадений. В компоненте `city-select`, если у города несколько аэропортов, каждый аэропорт сопровождается `pTooltip` с полным названием (`airport.name`, позиция `top`). 4. **Выбор значения** — пользователь выбирает пункт списка, поле привязывается к `state.departure` через `ngModel`. 5. **Валидация кода** — сеттер `departure` вызывает `updateCalendar()`, который через `validationService.validateCode()` проверяет код станции. 6. **Обновление календаря** — если оба кода валидны, вызывается `apiService.getFlightDaysByRoute()` и формируется массив `disabledDates`. diff --git a/docs/user-stories-4-schedule-results.md b/docs/user-stories-4-schedule-results.md index 1c716c2d..3bde0dca 100644 --- a/docs/user-stories-4-schedule-results.md +++ b/docs/user-stories-4-schedule-results.md @@ -12,7 +12,7 @@ 4. **Вкладки недель** — под заголовком расположен `week-tabs` (понедельник–воскресенье) с активной текущей неделей. 5. **Список дней** — `schedule-days` рендерит аккордеон по дням недели; по умолчанию раскрыт день `state.selectedDate`, иначе сегодня, иначе первый непустой. 6. **Карточки рейсов** — внутри раскрытого дня `list-scheduled-flight` показывает рейсы, первый из которых развёрнут по умолчанию. -7. **Переключение направления** — при наличии обратного рейса пользователь может переключать `OUTBOUND`/`INBOUND` через контрол направления. +7. **Переключение направления** — при наличии обратного рейса пользователь может переключать `OUTBOUND`/`INBOUND` через контрол `schedule-direction-switch`. Кнопки-иконки самолётов сопровождаются `pTooltip`: `SHARED.DIRECT_FLIGHTS` для исходящего направления и `SHARED.RETURN_FLIGHTS` для обратного (позиция `top`, стиль `afl-tooltip`). **Критерии приёмки:** @@ -20,7 +20,7 @@ - ✅ Заголовок, week-tabs, schedule-days и карточки рейсов отрисованы - ✅ Первый день с рейсами раскрыт автоматически - ✅ При пустом расписании показывается `page-empty-list` -- ✅ Переключение OUTBOUND/INBOUND меняет отображаемый набор рейсов +- ✅ Переключение OUTBOUND/INBOUND меняет отображаемый набор рейсов; кнопки направления имеют `pTooltip` (`SHARED.DIRECT_FLIGHTS` / `SHARED.RETURN_FLIGHTS`) - ✅ Макет адаптивен под мобильные экраны --- diff --git a/docs/user-stories-5-flight-details.md b/docs/user-stories-5-flight-details.md index c28f00f1..71bf7c06 100644 --- a/docs/user-stories-5-flight-details.md +++ b/docs/user-stories-5-flight-details.md @@ -55,7 +55,8 @@ 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` (смена даты в онлайн-табло) через обработчики компонента. +6. **Индикатор смены дня** — в компонентах времени (`time-group`, `time-group-legacy`) при пересечении полуночи рендерится `day-change-square` (или `day-change-square-legacy`) с `pTooltip`, показывающим дату смены через пайп `dayChange` (позиция `top`, стиль `afl-tooltip`). +7. **Обработка событий** — страница подписана на `open` (навигация по сегментам) и `dateChange` (смена даты в онлайн-табло) через обработчики компонента. **Критерии приёмки:** @@ -63,6 +64,7 @@ - ✅ Пока данные не загружены, пользователю показан индикатор загрузки. - ✅ Рендерится компонент мета-тегов для SEO. - ✅ В макет проецируются заголовок и все секции деталей, предусмотренные Angular-шаблоном. +- ✅ Индикатор смены дня (`day-change-square`) сопровождается `pTooltip` с датой пересечения полуночи. - ✅ События `open` и `dateChange` корректно пробрасываются в обработчики страницы. --- @@ -140,7 +142,7 @@ **Путь клиента:** -1. **Отображение логотипа** — компонент `operator-logo` рендерит блок `company-logo` с классами, вычисляемыми из оператора рейса (свойство `classes`). +1. **Отображение логотипа** — компонент `operator-logo` рендерит блок `company-logo` с классами, вычисляемыми из оператора рейса (свойство `classes`). При код-шеринге компонент отображает фактического оператора рейса (`actualOperator`), а не маркетингового перевозчика. 2. **Подпись блока** — при входном флаге `caption` сверху показывается описание `SHARED.AVIACOMPANY`. 3. **Подсказка оператора** — блок логотипа содержит атрибут `pTooltip` с названием `operatingBy`, что при наведении показывает человеко-читаемое название авиакомпании. 4. **Атрибут для тестирования** — у элемента задан `data-testid="flight-company-logo"` для автотестов и селекторов. @@ -152,6 +154,7 @@ - ✅ Подпись `SHARED.AVIACOMPANY` показывается только при активном флаге `caption`. - ✅ На наведении отображается тултип с именем оператора. - ✅ У элемента присутствует `data-testid="flight-company-logo"`. +- ✅ При код-шеринге показывается логотип фактического оператора, а не маркетингового перевозчика. - ✅ Компонент не создаёт лишнего контента внутри `company-logo`. --- @@ -164,7 +167,7 @@ 1. **Отображение города** — компонент `station` выводит название города через `text` с параметрами выравнивания, размера и жирности (`cityStyles`) и включённым `ellipsis`. 2. **Тултип города** — при наведении на название показывается полный `city` через `tooltip`. -3. **Ссылка на терминал** — под городом выводится `terminal-link`, связанный с `station`, что даёт пользователю информацию о номере терминала. +3. **Ссылка на терминал** — под городом выводится `terminal-link`, связанный с `station`, что даёт пользователю информацию о номере терминала. Элемент имеет `pTooltip` с полным названием аэропорта и терминалом в формате скобок (`station | airportTerminal: 'brackets'`, позиция `top`, стиль `afl-tooltip`). 4. **Отображение старого терминала** — при наличии `oldStation` показывается второй `terminal-link` с флагом `oldValue`, визуально отмечающим предыдущий терминал. 5. **Отображение старого города** — при `shouldShowOldCity` выводится `text` с `oldCity` красного цвета и размера 12, сигнализируя пользователю об изменении станции. 6. **Использование в заголовках и списках** — компонент переиспользуется в бейджах деталей, списках рейсов и секциях маршрута, сохраняя одинаковое поведение. @@ -172,7 +175,7 @@ **Критерии приёмки:** - ✅ Название города отображается с усечением и тултипом. -- ✅ Терминал выводится через компонент `terminal-link` для текущей станции. +- ✅ Терминал выводится через компонент `terminal-link` для текущей станции; элемент имеет `pTooltip` с аэропортом и терминалом в формате скобок. - ✅ При смене станции показывается второй `terminal-link` для старого значения. - ✅ При смене города показывается старое название красным цветом. - ✅ Компонент корректно переиспользуется во всех местах, где он встраивается в Angular-приложении. @@ -208,16 +211,18 @@ **Путь клиента:** 1. **Рендер `flight-actions`** — компонент принимает флаги `print`, `share`, `buy`, `register`, `status`, `details`, `wide` и рейс `flight`, а также `viewType`. -2. **Кнопка печати** — при `print` отображается `print-button` с данными рейса для вывода страницы на печать. +2. **Кнопка печати** — при `print` отображается `print-button` с данными рейса для вывода страницы на печать. Кнопка имеет `pTooltip` с текстом `SHARED.FLIGHT-INFO` + номер рейса (позиция `top`, стиль `afl-tooltip tooltip--one-line`). 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`. +6. **Кнопки статуса и деталей** — при `status && canViewStatus` выводится `flight-status-button` с `pTooltip` `SHARED.DETAILS-TOOLTIP` (позиция `top`, стиль `afl-tooltip`); при `details` — `flight-details-button`, эмитирующий событие `toDetails` через обработчик `handleToDetailsEvent`. **Критерии приёмки:** - ✅ Кнопки `buy`, `register`, `status` рендерятся при истинности входного флага И соответствующего `can*`-условия (`canBuy`, `canRegister`, `canViewStatus`). Кнопки `print`, `share`, `details` зависят только от входного флага. - ✅ `share-button` открывает панель шаринга с абсолютной ссылкой на детали. +- ✅ `print-button` сопровождается `pTooltip` с текстом `SHARED.FLIGHT-INFO` + номер рейса. +- ✅ `flight-status-button` сопровождается `pTooltip` `SHARED.DETAILS-TOOLTIP`. - ✅ `buy-ticket-button` получает `flight` как `direct`. - ✅ `flight-details-button` эмитит событие `toDetails`, обработанное страницей. - ✅ При флаге `wide` добавляется разделитель `k-space` перед блоком покупки. @@ -231,7 +236,7 @@ **Путь клиента:** 1. **Обёртка секции** — компонент `flight-details-full-route` рендерит элемент `section.frame`, группирующий содержимое блока маршрута. -2. **Таймлайн** — внутри `section.frame` выводится компонент `timeline` с массивом `legs`, показывающий последовательность сегментов рейса. +2. **Таймлайн** — внутри `section.frame` выводится компонент `timeline` с массивом `legs`, показывающий последовательность сегментов рейса. При смене станции в сегменте рендерится `station-change` с `pTooltip`, показывающим название города (`from?.latest.city`, позиция `top`, стиль `afl-tooltip`). 3. **Возможность изменения сегментов** — флаг `canChange` у таймлайна равен `viewType === ViewType.Onlineboard`, включая интерактивность только на онлайн-табло. 4. **Отключённый `flight-brief`** — в шаблоне присутствует закомментированный через `*ngIf="false"` компонент `flight-brief` (отключён по задаче 8072); он не отображается. 5. **Использование на обеих страницах** — компонент применяется и в онлайн-табло, и в расписании с разницей только в `viewType`. @@ -300,7 +305,7 @@ 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*`-флагах, предотвращая вызов недоступных действий. +4. **Действия** — кнопки `buy`, `register`, `status` во `flight-actions` активируются при соответствующих `can*`-флагах (`canBuy`, `canRegister`, `canViewStatus`), предотвращая вызов недоступных действий. 5. **События страницы** — обработчики `handleOpenEvent`, `handleDateChange`, `handleToDetailsEvent` получают события без выбрасывания ошибок. 6. **Верификация через автотесты** — существующие `*.spec.ts` для `route-status`, `flight-actions`, `share-button`, `print-button`, `operator-logo` подтверждают стабильность компонентов. @@ -309,7 +314,7 @@ - ✅ При загрузке страницы деталей не возникает ошибок в консоли. - ✅ Все условные блоки корректно скрываются при отсутствии данных. - ✅ Обработчики событий страницы отрабатывают без исключений. -- ✅ Действия выполняются только при активных `can*`-флагах. +- ✅ Действия `buy`, `register`, `status` выполняются только при активных `canBuy`, `canRegister`, `canViewStatus`-флагах. - ✅ Unit-тесты ключевых компонентов проходят. --- @@ -333,6 +338,6 @@ - ✅ Ни одна секция не подставляет значения «по умолчанию» при отсутствии данных. - ✅ Секции маршрута, расписания и статуса согласованы по сегментам `legs`. - ✅ Услуги, места и предыдущий рейс отображаются только при наличии соответствующих полей. -- ✅ Кнопки действий отражают реальные возможности рейса через `can*`-флаги. +- ✅ Кнопки действий `buy`, `register`, `status` отражают реальные возможности рейса через `canBuy`, `canRegister`, `canViewStatus`-флаги. --- diff --git a/docs/user-stories-6-flights-map.md b/docs/user-stories-6-flights-map.md index 54d74289..30b66018 100644 --- a/docs/user-stories-6-flights-map.md +++ b/docs/user-stories-6-flights-map.md @@ -16,7 +16,7 @@ - ✅ Вкладка "Карта полётов" видна только при включённом feature flag. - ✅ Клик по вкладке переводит пользователя на страницу карты без перезагрузки приложения. -- ✅ До готовности справочников отображается индикатор загрузки. +- ✅ До готовности справочников и при загрузке маршрутов отображается `loader-sheet` — полупрозрачный оверлей с анимированной иконкой самолёта поверх карты. - ✅ После инициализации отображаются компоненты `flights-map-filter` и `flights-map-body`. - ✅ Если feature flag выключен, вкладка не рендерится в навигации. @@ -64,7 +64,7 @@ **Критерии приёмки:** -- ✅ Поле "Откуда" использует компонент `city-autocomplete` с автодополнением по справочнику городов. +- ✅ Поле "Откуда" использует компонент `city-autocomplete` с автодополнением по справочнику городов; при наличии нескольких аэропортов у города каждый показывает `pTooltip` с полным названием. - ✅ Выбор города вызывает `filterStateService.setDeparture(code)`. - ✅ Маркер выбранного города меняет иконку на оранжевую. - ✅ При выборе только города отправления рисуется "паук" из всех доступных направлений. @@ -86,6 +86,7 @@ 4. **Шаг 4 — подсветка обоих маркеров** — маркеры отправления и прибытия получают иконку `markerOrange` и переносятся в `highlightedLayer`. 5. **Шаг 5 — запрос маршрута** — срабатывает `fetchAndDrawRoute(departure, arrival, dateFrom, dateTo, connections)`; если прямых рейсов нет, автоматически делается повторный запрос с `connections: 1`. 6. **Шаг 6 — отрисовка маршрута** — отображаются все найденные маршруты (прямые сплошной линией, с пересадками — пунктиром) и показываются popup в точках отправления и прибытия. +7. **Шаг 7 — отсутствие направлений** — если ни прямых, ни пересадочных маршрутов не найдено, поверх карты показывается `no-directions-sheet` — полупрозрачный оверлей с карточкой `FLIGHTS-MAP.NO_DIRECTIONS_INFO`. Клик вне карточки закрывает оверлей. **Критерии приёмки:** @@ -94,6 +95,7 @@ - ✅ Оба маркера (отправления и прибытия) подсвечиваются оранжевой иконкой. - ✅ При отсутствии прямых рейсов автоматически делается fallback-запрос с пересадками. - ✅ Если fallback вернул рейсы, тумблер "Соединительные" автоматически включается (`setConnections(true)`). +- ✅ Если маршрутов нет вообще, показывается оверлей `no-directions-sheet` с информационным сообщением. **Примечание:** Клик по маркеру при уже выбранном отправлении автоматически устанавливает его как прибытие. @@ -142,6 +144,7 @@ - ✅ Изменение даты вызывает `filterStateService.setDate(date)`. - ✅ При наличии маршрутов popup с ссылкой на бронирование обновляется под новую дату. - ✅ Изменение даты не приводит к повторному запросу списка маршрутов (карта не меняется, меняется только ссылка). +- ✅ При изменении доступных дат (`disabledDates`), если текущая выбранная дата стала недоступной, автоматически выбирается ближайшая доступная дата (date snapping). **Примечание:** Сам запрос направлений использует окно `[вчера; +6 месяцев]` — выбранная дата влияет только на ссылку "Купить билет". diff --git a/docs/user-stories-7-errors-accessibility.md b/docs/user-stories-7-errors-accessibility.md index c6925afe..4816b672 100644 --- a/docs/user-stories-7-errors-accessibility.md +++ b/docs/user-stories-7-errors-accessibility.md @@ -11,15 +11,17 @@ 3. **Страница ошибки загружается — ErrorPageComponent** — компонент читает `errorCode`, `title`, `description` из `route.snapshot.data`. 4. **Отображение сообщения — код и текст** — на экране виден код «404», заголовок `PAGE404.HEADER` и описание `PAGE404.DESCRIPTION`. 5. **Скрытие баннеров хоста — hideAflComponents()** — страница скрывает `.afl-component--banners` на время показа ошибки. -6. **Возврат к работе — переход на главную** — пользователь нажимает кнопку и возвращается на `/onlineboard` или в поиск. +6. **Поле поиска на странице ошибки** — на странице отображается текстовое поле с кнопкой поиска; ввод запроса и клик по кнопке открывает результаты поиска на aeroflot.ru в новой вкладке. +7. **Возврат к работе — переход на главную** — пользователь нажимает кнопку и возвращается на `/onlineboard` или в поиск. **Критерии приёмки:** - ✅ Маршрут `/error/404` отображает страницу с кодом 404. - ✅ Любой неизвестный URL приводит к редиректу на `/error/404`. - ✅ Заголовок и описание берутся из переводов (`PAGE404.*`). +- ✅ Поле поиска позволяет выполнить поиск на aeroflot.ru из страницы ошибки (открывается в новой вкладке). - ✅ Кнопка «На главную» возвращает на рабочий раздел. -- ✅ Баннеры хост-приложения скрыты на время показа ошибки и восстанавливаются при уходе со страницы. +- ✅ Баннеры хост-приложения скрыты на время показа ошибки и восстанавливаются при уходе со страницы (`ngOnDestroy`). **Примечание:** URL онлайн-табло — `/onlineboard` (без дефиса). Компонент ошибки — общий для 404 и 500, различие задаётся через `route.data`. @@ -434,3 +436,97 @@ **Примечание:** Источник истины — `shared/services/cache.service.ts`. Таймер на каждую `set()` перезапускается (`clearTimeout`), поэтому окно отсчитывается от последнего обновления записи. --- + +## US-105: Обновление данных в реальном времени (SignalR) + +**Цель:** Данные онлайн-табло автоматически обновляются при поступлении серверных событий, чтобы пользователь видел актуальную информацию без ручной перезагрузки. + +**Путь клиента:** + +1. **Подключение к хабу** — при загрузке страницы результатов или деталей рейса `RefreshBoardService` / `RefreshService` инициализирует SignalR-соединение с `environment.urlForTrackerHub`. +2. **Подписка на события** — сервис подписывается на события `RefreshDate` (для списка рейсов) или `Refresh` (для деталей рейса по ID). +3. **Тихое обновление** — при поступлении события данные перезагружаются с флагом `silent`, без показа индикатора загрузки, чтобы не отвлекать пользователя. +4. **Оверлей устаревших данных** — `FadeService` запускает таймер; при бездействии пользователя в течение `environment.refreshPauseMin` минут поверх страницы появляется полупрозрачный оверлей с сообщением «Данные устарели, обновите страницу!». +5. **Автоматический редирект** — если бездействие превышает `environment.refreshStopMin` минут, приложение перенаправляет пользователя на главную страницу (`/`). +6. **Очистка** — при уходе со страницы (`ngOnDestroy`) подписки SignalR и таймеры `FadeService` очищаются. + +**Критерии приёмки:** + +- ✅ На страницах результатов онлайн-табло устанавливается SignalR-соединение для получения обновлений. +- ✅ На странице деталей рейса подписка привязана к конкретному ID рейса. +- ✅ Обновление данных происходит без видимого индикатора загрузки (silent reload). +- ✅ После `refreshPauseMin` минут бездействия показывается полноэкранный оверлей (z-index 9999, полупрозрачный фон). +- ✅ После `refreshStopMin` минут бездействия происходит автоматический редирект на главную. +- ✅ SignalR-подписки и таймеры корректно очищаются при уничтожении компонентов. + +**Примечание:** Функция актуальна только для онлайн-табло (реальное время). Расписание и карта полётов не используют SignalR. `FadeService` создаёт overlay программно через DOM API (`document.createElement`). + +--- + +## US-106: Встроенный чат-бот + +**Цель:** Пользователь может воспользоваться чат-ботом для получения справочной информации. + +**Путь клиента:** + +1. **Инициализация** — компонент `chat-bot` в `ngAfterViewInit` динамически создаёт `<script>` элемент с URL из `environment.chatBotScript`. +2. **Загрузка виджета** — скрипт монтируется к `#app-chat-bot-container`, загружая внешний чат-виджет. +3. **Использование** — после загрузки пользователь видит виджет чата и может задавать вопросы. + +**Критерии приёмки:** + +- ✅ Чат-бот загружается динамически через внешний скрипт из `environment.chatBotScript`. +- ✅ Виджет монтируется в контейнер `#app-chat-bot-container`. +- ✅ Ошибки загрузки скрипта не ломают остальной интерфейс. + +**Примечание:** URL скрипта настраивается через конфигурацию окружения. + +--- + +## US-107: Canonical URL и SEO мета-теги по страницам + +**Цель:** Каждая страница приложения имеет корректные canonical URL и уникальные SEO мета-теги для правильной индексации поисковыми системами. + +**Путь клиента:** + +1. **Установка canonical** — `CanonicalService` формирует `<link rel="canonical">` для текущей страницы. +2. **Мета-теги по разделам** — каждый раздел (онлайн-табло, расписание, карта полётов) имеет собственные компоненты мета-тегов с уникальными ключами `SEO.*`: + - Онлайн-табло: стартовая, результаты (по номеру/маршруту/отправлению/прибытию), детали рейса + - Расписание: стартовая, результаты, детали рейса + - Карта полётов: стартовая +3. **Динамические значения** — для страниц результатов и деталей мета-теги включают названия городов, даты и номера рейсов. +4. **OpenGraph** — для страниц деталей формируются `og:title`, `og:description`, `og:image` теги. +5. **noRobots** — на страницах с динамическими параметрами (результаты, детали) установлен `noRobots: true` для предотвращения индексации. + +**Критерии приёмки:** + +- ✅ Canonical URL корректен для каждой страницы. +- ✅ Мета-теги `title` и `description` уникальны для каждого типа страницы. +- ✅ Динамические страницы включают контекст (город, дата, номер рейса) в мета-теги. +- ✅ Стартовые страницы разделов индексируются (`noRobots: false`), страницы результатов — нет. +- ✅ OpenGraph-теги формируются для страниц деталей. + +**Примечание:** Компоненты мета-тегов наследуют от `MetaBaseComponent` и переопределяют `translateTitle()`/`translateDescription()`. + +--- + +## US-108: Отображение версии API (debug-режим) + +**Цель:** В debug-режиме разработчик может видеть текущую версию API для диагностики. + +**Путь клиента:** + +1. **Проверка флага** — `AppVersionComponent` проверяет `settings.showDebugVersion` из `APP_SETTINGS`. +2. **Запрос версии** — если флаг включён, `NetworkService.getApiVersion()` загружает номер версии. +3. **Отображение** — версия выводится компонентом `app-version` рядом с навигацией. +4. **Скрытие баннера** — при включённом debug-режиме скрывается элемент `.banner--top`. + +**Критерии приёмки:** + +- ✅ В обычном режиме версия не отображается. +- ✅ При `showDebugVersion = true` показывается номер версии API. +- ✅ Компонент `app-show-debug` условно рендерит debug-информацию. + +**Примечание:** Предназначено для разработки и тестирования, не для конечных пользователей. + +--- diff --git a/src/env/index.ts b/src/env/index.ts index af075ede..2c5f2b03 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -9,6 +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"), + // In dev, points to Angular's proxy (:4200) which forwards to flights.test.aeroflot.ru. + // In production, this would be the customer's API gateway. 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(), diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 9693c7fe..5c1850de 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -96,7 +96,7 @@ function toCalendarParams( params: OnlineBoardSearchPageProps["params"], ): CalendarParams { const base: CalendarParams = { - date: params.date, + date: formatDateForApi(params.date), searchType: params.type as FlightRequestType, }; diff --git a/src/features/online-board/hooks/useOnlineBoard.ts b/src/features/online-board/hooks/useOnlineBoard.ts index 4e4474c1..6a6307bc 100644 --- a/src/features/online-board/hooks/useOnlineBoard.ts +++ b/src/features/online-board/hooks/useOnlineBoard.ts @@ -50,13 +50,15 @@ export function useOnlineBoard( searchFlights(client, paramsRef.current) .then((response) => { if (!cancelled) { - setFlights(response.data.routes); + setFlights(response.data?.routes ?? []); setLoading(false); } }) - .catch((err: ApiError) => { + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error("[useOnlineBoard] API error:", err); if (!cancelled) { - setError(err); + setError(err as ApiError); setLoading(false); } }); diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 68e22a3d..48e90d91 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -35,7 +35,7 @@ export class ApiClient { this.baseUrl = options.baseUrl.replace(/\/$/, ""); this.locale = options.locale; this.traceId = options.traceId; - this.fetchFn = options.fetchImpl ?? globalThis.fetch; + this.fetchFn = options.fetchImpl ?? globalThis.fetch.bind(globalThis); this.timeoutMs = options.defaultTimeoutMs ?? 5000; this.maxRetries = options.retry?.maxRetries ?? 3; this.timeoutFactor = options.retry?.timeoutFactor ?? 2;