343 lines
32 KiB
Markdown
343 lines
32 KiB
Markdown
# RideBus PWA
|
||
|
||
## Возможности
|
||
|
||
- Четыре раздела: «Главная», «Остановки», «Избранное», «Настройки».
|
||
На главной компактный выбор города и остановки; окно выбора содержит
|
||
каталог. Общий список и поиск рядом переключаются,
|
||
чтобы не занимать экран двумя списками. На телефоне выбор остановки
|
||
закрывает окно и плавно переводит к отправлениям, в том числе для выбранной даты.
|
||
- Знакомство при первом посещении: пять подсказок с подсветкой настоящих
|
||
функций главной страницы. Можно пропустить, пройти с клавиатуры или повторить
|
||
из настроек. Завершение/пропуск сохраняются в этом браузере.
|
||
- Города из RideBus API, поиск остановок без повторяющихся названий и выбор направления чипами в карточке отправлений.
|
||
- «Остановки» — самостоятельная карта и справочник, без второй карточки
|
||
отправлений. На карте отдельные точки посадки для разных направлений,
|
||
сгруппированные при отдалении; список показывает каждое название один раз.
|
||
Поиск синхронизирует список и точки. Карточка остановки содержит направления,
|
||
действующие маршруты, добавление в избранное и построение пешего пути внутри приложения.
|
||
«Открыть расписание» переводит на Главную с выбранной точкой посадки,
|
||
сохраняет дату просмотра и прокручивает к отправлениям.
|
||
В режиме списка на десктопе карточка занимает всю правую область.
|
||
На телефоне карточка открывается диалогом по нажатию, в обоих режимах.
|
||
Диалог закрывается кнопкой, нажатием вне окна или Escape и возвращает фокус;
|
||
список не удлиняется из-за карточки. При входе в раздел мобильный диалог
|
||
не открывается автоматически из-за ранее выбранной остановки.
|
||
Кнопка геолокации в поиске показывает остановки в радиусе 1 км;
|
||
расстояния рассчитываются по прямой, координаты пользователя остаются в памяти.
|
||
Без сети доступен список и ранее просмотренные карточки остановок.
|
||
MapLibre загружается только при открытии карты; библиотека и её worker не входят
|
||
в предзагрузку офлайн-оболочки. Светлая/тёмная подложки
|
||
[OpenFreeMap](https://openfreemap.org/quick_start/) следуют теме приложения,
|
||
требуют подключения и не нуждаются в отдельном ключе. Атрибуция источников сохранена.
|
||
- Фильтр по маршруту синхронизирует список и карту. Одинаковые номера
|
||
объединяются, направления выбираются отдельно; при выборе одного направления
|
||
остановки идут в порядке движения. Названия не повторяются, точки посадки
|
||
и их идентификаторы сохраняются. Фильтр остаётся в URL (`line`, `direction`)
|
||
после перезагрузки; просмотренные направления доступны без сети.
|
||
Поиск по названию и рядом работает вместе с фильтром. При переходе к расписанию
|
||
выбранный маршрут сохраняется. Частично недоступные направления обозначаются,
|
||
не заменяются полным списком города. Смена города сбрасывает фильтр.
|
||
- «Как добраться пешком» запрашивает местоположение по нажатию и использует
|
||
RideBus OSRM `/routing/foot/route/v1/foot/…` с геометрией GeoJSON.
|
||
Путь, расстояние и приблизительное время показываются в отдельном диалоге.
|
||
При отказе в геолокации или ошибке доступны повтор и внешние карты.
|
||
В демонстрации построение пути отключено, чтобы примеры не выдавать за реальный маршрут.
|
||
Начальная точка и геометрия пешего пути остаются в памяти, не сохраняются
|
||
в IndexedDB/localStorage и серверном кеше приложения.
|
||
- Ближайшие отправления и полное расписание остановки на выбранную дату,
|
||
переключение направлений и фильтр по маршруту. В компактной карточке
|
||
название и закладка находятся рядом, кнопка «+» сохраняет поездку,
|
||
а на телефоне фильтры маршрутов прокручиваются горизонтально.
|
||
- Полное расписание в сетке по часам без скрытых рейсов; переход к ближайшему
|
||
времени и открытие конкретного рейса нажатием на отправление.
|
||
- «Мои поездки»: город, остановка, направление и маршрут в одной карточке.
|
||
Можно сохранить «Домой», «На работу» или своё название, переименовать и удалить.
|
||
На мобильном несколько поездок листаются каруселью; на десктопе карусель
|
||
включается от трёх поездок. Доступны свайпы, кнопки и управление с клавиатуры.
|
||
Частично видимые карточки плавно затухают у края. На первом и последнем
|
||
положении затухание остаётся только со стороны скрытых поездок.
|
||
«Открыть поездку» выбирает нужный город/направление/маршрут и прокручивает страницу
|
||
к карточке отправлений. При уменьшении движения переход происходит без анимации.
|
||
- Экран рейса с последовательностью остановок и временем на каждой из них,
|
||
с учётом укороченных/обратных рейсов и перехода через полночь.
|
||
- Кнопка «Поделиться отправлением» у ближайших отправлений и на экране рейса.
|
||
Ссылка сохраняет город, остановку, маршрут, дату и идентификатор конкретного рейса.
|
||
Используется системное меню отправки, затем копирование ссылки, если меню
|
||
недоступно. При недоступном буфере обмена можно скопировать ссылку вручную.
|
||
Отмена системного меню не копирует ссылку и не показывает ошибку.
|
||
- Активные сообщения об отменах и изменениях рядом с затронутыми маршрутами.
|
||
Полный текст сохраняется, сообщения нескольких маршрутов не дублируются,
|
||
а закончившиеся сообщения исчезают даже без сети.
|
||
- Кнопка «Остановки рядом»: запрос местоположения только после нажатия,
|
||
остановки в радиусе 1 км с расстояниями и группировкой одинаковых названий.
|
||
Отказ в разрешении не мешает обычному поиску. Координаты и результаты поиска
|
||
рядом не сохраняются в IndexedDB/localStorage и серверном кеше приложения.
|
||
Обычный список и поиск рядом используют общие строки, состояния и скроллбары.
|
||
- Ожидание в часах и минутах: «через 2 ч 1 мин».
|
||
- Избранные остановки с тремя ближайшими отправлениями прямо в карточках,
|
||
сохранением в браузере и переходом к расписанию.
|
||
- Короткие микроанимации карточек, кнопок, фильтров, закладки и счётчика избранного.
|
||
Эффекты отключаются при системной настройке уменьшения движения.
|
||
- Просмотр ранее загруженных данных без интернета. Кеш разделён по городам,
|
||
остановкам и режиму работы; время сохранения видно в интерфейсе.
|
||
Отключение сети показывает один короткий общий статус. Свежие снимки
|
||
не получают предупреждение об устаревании только из-за потери соединения.
|
||
Предупреждение появляется по времени последнего обновления: от 30 минут
|
||
для ближайших отправлений, 24 часов для расписания на дату/рейса и 15 минут
|
||
для сообщений о движении. Это пороги напоминания об обновлении, а не гарантия
|
||
неизменности расписания. Ошибки обновления онлайн и частичные результаты
|
||
продолжают обозначаться отдельно.
|
||
- Установка PWA, локальные шрифты, иконки, офлайн-оболочка и предложение обновления.
|
||
- Раздел «Настройки» в основной навигации, нижней панели на телефоне и футере.
|
||
Светлая, тёмная и системная темы применяются ко всем экранам. По умолчанию
|
||
оформление следует теме устройства и реагирует на её изменения.
|
||
Выбор сохраняется в этом браузере, синхронизируется между его вкладками
|
||
и доступен без сети. Сохранённая тема применяется ещё до запуска React.
|
||
При заблокированном хранилище переключение работает до закрытия вкладки;
|
||
приложение сообщает, что сохранить выбор не удалось.
|
||
- Отдельный демонстрационный режим `/?demo=1` с явно помеченными примерами.
|
||
Кнопка «Посмотреть пример» есть только при `npm run dev`; в релизной сборке
|
||
и Docker её нет, включая экран ошибки загрузки. Прямая ссылка на демонстрацию
|
||
сохранена для ручных проверок и тестов. Она не подменяет реальные данные при ошибке API.
|
||
|
||
В режиме «Ближайшие» API ETA возвращает ограниченное количество отправлений
|
||
для каждого маршрута. Историческая задержка исключается: показаны времена по
|
||
расписанию, а не обещание реального прибытия. В режиме «На дату» сервер получает
|
||
полное расписание маршрутов, учитывает праздничный тип дня, ограничения рейсов
|
||
по дням недели, начальную/конечную остановку укороченных рейсов и время пути.
|
||
В выбранную календарную дату включаются и рейсы предыдущего дня, достигающие
|
||
остановки после полуночи. Частичные ошибки обозначаются в интерфейсе.
|
||
|
||
Просмотренное полное расписание сохраняется отдельно для каждой даты и доступно
|
||
офлайн, включая прошедшие рейсы. Ближайшие отправления и карточки избранного
|
||
удаляют прошедшие времена из показа. Это не офлайн-база всех городов и дат:
|
||
сначала нужно открыть нужную остановку/дату в сети. Браузер может очищать
|
||
локальное хранилище. Просмотренные рейсы и сообщения также доступны из сохранённых
|
||
данных с предупреждением об устаревании. Поиск рядом требует сети и разрешения
|
||
браузера на местоположение; в демонстрации расстояния явно помечены как примеры.
|
||
Карты с движением транспорта, пушей и аккаунтов пока нет.
|
||
|
||
Расписание рейса показывает плановые времена, а не положение автобуса. Если
|
||
несколько вариантов приходят одновременно, ссылка из полного расписания
|
||
содержит идентификатор конкретного рейса. При изменившемся расписании приложение
|
||
предлагает выбрать отправление заново. Сообщения об отменах информируют пользователя,
|
||
но не удаляют времена из исходного расписания. На будущую дату рядом могут
|
||
отображаться текущие сообщения; в каждом виден полный период действия.
|
||
|
||
## Запуск
|
||
|
||
Требуется Node.js 22.12+ (проверено с Node.js 24).
|
||
|
||
```sh
|
||
npm install
|
||
cp .env.example .env
|
||
```
|
||
|
||
В `.env` задайте `RIDEBUS_API_KEY`. При создании этого проекта локальный файл
|
||
уже настроен по конфигурации лендинга. `.env` исключён из Git; ключ используется
|
||
только сервером и не попадает в JavaScript или запросы браузера.
|
||
|
||
```sh
|
||
npm run dev
|
||
```
|
||
|
||
Приложение: <http://localhost:5173>. Один Node-сервер обслуживает Vite и API,
|
||
поэтому отдельный процесс прокси не нужен. В development service worker отключён.
|
||
|
||
Для проверки установки и офлайн-работы используйте production-сборку:
|
||
|
||
```sh
|
||
npm run build
|
||
npm start
|
||
```
|
||
|
||
Порт задаётся переменной `PORT`, адрес — `HOST` (по умолчанию `127.0.0.1`).
|
||
При запуске в контейнере или на сервере используйте `HOST=0.0.0.0` и HTTPS
|
||
на внешнем reverse proxy. `localhost` допускает service worker без HTTPS.
|
||
Раздачи одного `dist/` недостаточно: нужен серверный обработчик `/api/data`.
|
||
|
||
## Развёртывание через Docker Compose
|
||
|
||
Требуются Docker Engine с Docker Compose v2 и HTTPS-прокси на сервере.
|
||
В `docker-compose.yml` один сервис `web`: он собирает фронтенд и запускает
|
||
Node-сервер, который обслуживает приложение и запросы к RideBus.
|
||
Образ собирается в несколько этапов; в конечном контейнере только `dist/`
|
||
и сервер без зависимостей сборки. Схема описана в
|
||
[документации Docker](https://docs.docker.com/build/building/multi-stage/).
|
||
|
||
На сервере скопируйте проект и создайте `.env`:
|
||
|
||
```sh
|
||
cp .env.example .env
|
||
```
|
||
|
||
Задайте `RIDEBUS_API_KEY`; при необходимости измените `RIDEBUS_API_BASE_URL`.
|
||
Ключ передаётся только процессу сервера при запуске контейнера; `.env` исключён
|
||
из контекста сборки. Compose остановится с понятной ошибкой, если ключ пустой.
|
||
`PWA_PORT=8080` и `PWA_BIND_ADDRESS=127.0.0.1` задают адрес на хосте.
|
||
Внутри контейнера `HOST=0.0.0.0` и `PORT=5173` фиксированы отдельно от настроек
|
||
локального запуска.
|
||
|
||
```sh
|
||
docker compose config --quiet
|
||
docker compose up -d --build --wait
|
||
docker compose ps
|
||
curl --fail http://127.0.0.1:8080/healthz
|
||
```
|
||
|
||
`/healthz` возвращает `{"status":"ok"}` при работающем HTTP-сервере.
|
||
Проверка не обращается к RideBus: ошибки источника расписаний отображаются
|
||
в самом приложении. Включены перезапуск процесса после сбоя, healthcheck
|
||
и ограничение размера логов. Контейнер работает от пользователя `node`
|
||
с файловой системой только для чтения и отдельным временным `/tmp`.
|
||
Параметры сервисов описаны в
|
||
[справочнике Compose](https://docs.docker.com/reference/compose-file/services/).
|
||
|
||
Направьте весь трафик вашего HTTPS-домена на `http://127.0.0.1:8080`, включая
|
||
`/api/`, `/healthz`, `/sw.js` и `/manifest.webmanifest`. Если Nginx уже работает
|
||
на хосте и TLS-сертификат настроен, добавьте в его HTTPS-блок `server`:
|
||
|
||
```nginx
|
||
location / {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
```
|
||
|
||
Если изменили `PWA_PORT`, укажите тот же порт в `proxy_pass`.
|
||
Не добавляйте кеширование для `/api/` и `/sw.js`. HTTPS нужен для установки
|
||
PWA, service worker, геолокации и системной отправки ссылки;
|
||
ограничения описаны в [MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts).
|
||
Если ваш прокси работает в другом контейнере, подключите его и `web` к общей
|
||
Docker-сети и используйте upstream `http://web:5173`.
|
||
|
||
После обновления исходников повторите `docker compose up -d --build --wait`.
|
||
Для просмотра логов и остановки:
|
||
|
||
```sh
|
||
docker compose logs --tail=100 -f web
|
||
docker compose down
|
||
```
|
||
|
||
Серверная база данных и постоянный volume приложению не нужны: избранное,
|
||
поездки и просмотренные расписания хранятся в браузере, а серверный кеш
|
||
создаётся в памяти заново после перезапуска.
|
||
|
||
## Стек и файлы
|
||
|
||
- React, TypeScript, Vite, React Router.
|
||
- Иконки `@vkontakte/icons`, счётчик избранного отдельным бейджем в мобильной навигации.
|
||
- Sass и цвета в `src/_tokens.scss`; адаптивные стили в `src/styles.scss`.
|
||
- `src/_motion.scss` — длительности, отклик элементов и анимации появления;
|
||
длинные списки ограничивают количество одновременно анимируемых строк.
|
||
- `src/_features.scss` — поездки, почасовая сетка, ближайшие остановки и экран рейса.
|
||
- TanStack Query для загрузки и обновления, Dexie/IndexedDB для снимков и избранного.
|
||
- `vite-plugin-pwa` / Workbox для manifest, service worker и офлайн-оболочки.
|
||
- `server/ridebus.mjs` — адаптер RideBus, нормализация, таймауты, кеш.
|
||
- `server/index.mjs` — Node HTTP-сервер и фиксированный список методов чтения.
|
||
- `Dockerfile`, `docker-compose.yml`, `.env.example` — релизная сборка и запуск на сервере.
|
||
- `src/App.tsx` — поиск, выбор города/остановки и избранное.
|
||
- `src/components/Departures.tsx` — отправления, фильтр, сохранённые данные.
|
||
- `src/components/FavoriteCard.tsx` — ближайшие рейсы в избранном.
|
||
- `src/components/About.tsx` — страница автора проекта.
|
||
- `src/components/QuickTrips.tsx` — сохранение и карточки поездок.
|
||
- `src/components/TripCarousel.tsx` — адаптивная карусель с кнопками, свайпами и клавиатурой.
|
||
- `src/components/ShareDeparture.tsx` — отправка/копирование ссылки на конкретное отправление.
|
||
- `src/components/StopList.tsx` — общие строки и контейнер для обоих списков остановок.
|
||
- `src/components/StopsExplorer.tsx`, `StopsMap.tsx`, `src/_stops.scss` — карта,
|
||
каталог и карточка остановки; `src/lib/geo.ts` — расстояния и пешие ссылки.
|
||
- `src/lib/useStopRoutes.ts`, `routeGroups.ts` — номера/направления маршрутов и их остановки.
|
||
- `src/components/WalkingDirections.tsx`, `server/walking.mjs` — пеший путь и проверка геометрии OSRM.
|
||
- `src/components/Settings.tsx`, `src/lib/theme.ts` — выбор темы и её сохранение.
|
||
- `src/components/FeatureDiscovery.tsx`, `src/_discovery.scss` — подсказки первого входа.
|
||
- `src/_navigation.scss`, `src/_departures.scss` — разделы, компактный выбор и отправления.
|
||
- `src/lib/freshness.ts` — возраст данных без привязки к наличию сети.
|
||
- `src/_tokens.scss` — цвета светлой и тёмной тем; `src/_settings.scss` — настройки.
|
||
- `src/components/HourlyTimetable.tsx`, `RoutePage.tsx` — почасовая сетка и конкретный рейс.
|
||
- `src/components/ServiceAlerts.tsx`, `NearbyStops.tsx` — изменения и поиск рядом.
|
||
- `src/lib/storage.ts` — версия 2 базы; существующие снимки/избранное сохраняются при обновлении.
|
||
- `server/timetable.mjs` — расчёт времени остановки по полному расписанию рейсов.
|
||
- `server/features.mjs` — нормализация сообщений и ближайших остановок.
|
||
|
||
Прокси использует `GET /api/v1/cities`, `GET /api/v1/stops` и
|
||
`GET /api/v1/eta/stop/:id`. Карточка остановки получает направления/маршруты через
|
||
`/api/data?resource=stop&cityId=…&stopId=…` → `GET /api/v1/stops/:id`;
|
||
координаты точек посадки и центра города нормализуются из исходных ответов.
|
||
Отсутствующие или неверные координаты не заменяются нулями: такие остановки
|
||
доступны в списке. Для фильтра используются `resource=routes` → `GET /api/v1/routes`
|
||
и `resource=routeStops&routeId=…` → `GET /api/v1/routes/:id`.
|
||
`resource=walk&stopId=…&lat=…&lon=…` берёт координаты назначения из остановки
|
||
и запрашивает `/routing/foot/route/v1/foot/lon,lat;lon,lat` без кеширования начала/пути.
|
||
Формат проверен по [документации OSRM](https://project-osrm.org/docs/v5.24.0/api/#route-service)
|
||
и живому RideBus. Для полного расписания используются
|
||
`GET /api/v1/stops/:id`, `GET /api/v1/routes/:id/stops` и
|
||
`GET /api/v1/routes/:id/schedule/today?date=YYYY-MM-DD`.
|
||
Экран рейса дополнительно использует `GET /api/v1/routes/:id`;
|
||
сообщения — `GET /api/v1/alerts/active`, поиск рядом —
|
||
`GET /api/v1/stops/nearby?lat=…&lon=…&radius=1000`.
|
||
Город передаётся в `X-City-Id`, ключ — в `X-API-Key`.
|
||
Браузер получает нормализованные данные с того же origin.
|
||
Параметры города/остановки доступны в URL (`/?city=1&stop=…`), в том числе
|
||
при переходе из избранного. Полное расписание использует
|
||
`/?city=1&stop=…&view=day&date=2026-10-07`.
|
||
Сервер кеширует полное расписание и его исходные данные на 5 минут,
|
||
разделяя города и даты; ограничивает параллельные запросы к RideBus шестью.
|
||
|
||
## Проверки
|
||
|
||
```sh
|
||
npm test
|
||
npm run typecheck
|
||
npm run build
|
||
PLAYWRIGHT_BROWSERS_PATH=.playwright npx playwright install chromium
|
||
npm run test:e2e
|
||
```
|
||
|
||
Playwright проверяет desktop и экран iPhone 13 в Chromium: поиск, фильтры,
|
||
сохранение избранного, ожидание в часах/минутах, выбор даты и направления,
|
||
страницу автора, поездки, миграцию хранилища, диалоги с клавиатурой,
|
||
почасовую сетку и экран рейса, разрешённую/отклонённую геолокацию,
|
||
отсутствие сохранённых координат, фильтрацию/истечение сообщений,
|
||
карусель от двух поездок на мобильном/трёх на десктопе, touch-свайпы,
|
||
переход к отправлениям между городами, уменьшение движения,
|
||
отправку/копирование/отмену и ручной резервный способ поделиться рейсом,
|
||
одинаковое оформление списков остановок,
|
||
затухание края карусели и отсутствие кнопок демо в релизном интерфейсе,
|
||
выбор/сохранение темы, системные изменения, синхронизацию вкладок,
|
||
первое отображение до запуска JavaScript-приложения и настройки без сети,
|
||
четыре раздела, компактность выбора на ширинах 320–1440 px,
|
||
выбор точки на карте, отдельные направления карточки остановки,
|
||
пешую ссылку, сохранение выбранной стороны в избранном,
|
||
полную ширину карточки в списке, мобильный диалог, номера/направления и порядок
|
||
остановок маршрута, сохранение фильтра без сети, частичный отказ направления,
|
||
пешую геометрию/метрики, отказ в разрешении и отсутствие сохранённого начала/пути,
|
||
список/просмотренную карточку остановки без сети и темы карты,
|
||
выбор остановки с прокруткой и уменьшением движения, переход на нужную дату,
|
||
один статус при отключении сети и предупреждение после реального старения данных,
|
||
первый вход с подсветкой, повторное обучение и подсказки на экране 320 × 568,
|
||
разделение городов, честную ошибку API и настоящий офлайн-перезапуск
|
||
через service worker, включая расписание на конкретную дату и рейс.
|
||
Это эмуляция размера экрана,
|
||
а не проверка на физическом iPhone/Safari. Тесты используют демонстрационные
|
||
данные и контролируемые ответы; координаты задаются в тестовом браузере.
|
||
Скриншоты сохраняются в `test-results/`.
|
||
|
||
```sh
|
||
npm run icons
|
||
```
|
||
|
||
Эта команда пересоздаёт PNG-иконки из исходного логотипа. Иконки и шрифты
|
||
поставляются вместе с приложением и не требуют сети после первого открытия.
|
||
|
||
## Виджет статуса RideBus
|
||
|
||
Встроенный iframe показывает свежий статус этого сервиса и API. При недоступности мониторинга отображается отсутствие данных. Адрес хостинга задаётся публичной переменной сборки: `VITE_STATUS_URL`. Если адрес не задан, production-виджет скрыт; в development используется http://127.0.0.1:5188. После изменения переменной нужна новая сборка.
|
||
|
||
Виджет не получает сессию пользователя, API-ключи или данные приложения. Состояния, оформление, русская/английская локализация и обновления обслуживаются проектом ridebus-status.
|
||
|
||
При запуске через Docker Compose укажите переменную URL в корневом `.env` проекта и пересоберите образ (`docker compose up -d --build`). URL передаётся как build argument; смена только runtime environment не обновляет уже собранный интерфейс.
|