initial commit
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.playwright
|
||||
.env
|
||||
.env.*
|
||||
dist
|
||||
test-results
|
||||
playwright-report
|
||||
.git
|
||||
qa
|
||||
e2e
|
||||
test
|
||||
*.tsbuildinfo
|
||||
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
RIDEBUS_API_BASE_URL=https://api.ridebus.by/
|
||||
RIDEBUS_API_KEY=
|
||||
PORT=5173
|
||||
HOST=127.0.0.1
|
||||
|
||||
# Docker Compose: port published on the server for an HTTPS reverse proxy.
|
||||
PWA_PORT=8080
|
||||
PWA_BIND_ADDRESS=127.0.0.1
|
||||
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY index.html tsconfig.json vite.config.ts ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production HOST=0.0.0.0 PORT=5173
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
USER node
|
||||
EXPOSE 5173
|
||||
CMD ["node", "server/index.mjs"]
|
||||
246
README.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# RideBus PWA
|
||||
|
||||
## Возможности
|
||||
|
||||
- Города из RideBus API, поиск остановок без повторяющихся названий и выбор направления чипами в карточке отправлений.
|
||||
- Ближайшие отправления и полное расписание остановки на выбранную дату,
|
||||
переключение направлений и фильтр по маршруту.
|
||||
- Полное расписание в сетке по часам без скрытых рейсов; переход к ближайшему
|
||||
времени и открытие конкретного рейса нажатием на отправление.
|
||||
- «Мои поездки»: город, остановка, направление и маршрут в одной карточке.
|
||||
Можно сохранить «Домой», «На работу» или своё название, переименовать и удалить.
|
||||
На мобильном несколько поездок листаются каруселью; на десктопе карусель
|
||||
включается от трёх поездок. Доступны свайпы, кнопки и управление с клавиатуры.
|
||||
Частично видимые карточки плавно затухают у края. На первом и последнем
|
||||
положении затухание остаётся только со стороны скрытых поездок.
|
||||
«Открыть поездку» выбирает нужный город/направление/маршрут и прокручивает страницу
|
||||
к карточке отправлений. При уменьшении движения переход происходит без анимации.
|
||||
- Экран рейса с последовательностью остановок и временем на каждой из них,
|
||||
с учётом укороченных/обратных рейсов и перехода через полночь.
|
||||
- Кнопка «Поделиться отправлением» у ближайших отправлений и на экране рейса.
|
||||
Ссылка сохраняет город, остановку, маршрут, дату и идентификатор конкретного рейса.
|
||||
Используется системное меню отправки, затем копирование ссылки, если меню
|
||||
недоступно. При недоступном буфере обмена можно скопировать ссылку вручную.
|
||||
Отмена системного меню не копирует ссылку и не показывает ошибку.
|
||||
- Активные сообщения об отменах и изменениях рядом с затронутыми маршрутами.
|
||||
Полный текст сохраняется, сообщения нескольких маршрутов не дублируются,
|
||||
а закончившиеся сообщения исчезают даже без сети.
|
||||
- Кнопка «Остановки рядом»: запрос местоположения только после нажатия,
|
||||
остановки в радиусе 1 км с расстояниями и группировкой одинаковых названий.
|
||||
Отказ в разрешении не мешает обычному поиску. Координаты и результаты поиска
|
||||
рядом не сохраняются в IndexedDB/localStorage и серверном кеше приложения.
|
||||
Обычный список и поиск рядом используют общие строки, состояния и скроллбары.
|
||||
- Ожидание в часах и минутах: «через 2 ч 1 мин».
|
||||
- Избранные остановки с тремя ближайшими отправлениями прямо в карточках,
|
||||
сохранением в браузере и переходом к расписанию.
|
||||
- Короткие микроанимации карточек, кнопок, фильтров, закладки и счётчика избранного.
|
||||
Эффекты отключаются при системной настройке уменьшения движения.
|
||||
- Просмотр ранее загруженных данных без интернета. Кеш разделён по городам,
|
||||
остановкам и режиму работы; время сохранения видно в интерфейсе.
|
||||
- Установка PWA, локальные шрифты, иконки, офлайн-оболочка и предложение обновления.
|
||||
- Отдельный демонстрационный режим `/?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/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`. Для полного расписания используются
|
||||
`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-свайпы,
|
||||
переход к отправлениям между городами, уменьшение движения,
|
||||
отправку/копирование/отмену и ручной резервный способ поделиться рейсом,
|
||||
одинаковое оформление списков остановок,
|
||||
затухание края карусели и отсутствие кнопок демо в релизном интерфейсе,
|
||||
разделение городов, честную ошибку API и настоящий офлайн-перезапуск
|
||||
через service worker, включая расписание на конкретную дату и рейс.
|
||||
Это эмуляция размера экрана,
|
||||
а не проверка на физическом iPhone/Safari. Тесты используют демонстрационные
|
||||
данные и контролируемые ответы; координаты задаются в тестовом браузере.
|
||||
Скриншоты сохраняются в `test-results/`.
|
||||
|
||||
```sh
|
||||
npm run icons
|
||||
```
|
||||
|
||||
Эта команда пересоздаёт PNG-иконки из исходного логотипа. Иконки и шрифты
|
||||
поставляются вместе с приложением и не требуют сети после первого открытия.
|
||||
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: ridebus-pwa
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: "5173"
|
||||
RIDEBUS_API_BASE_URL: ${RIDEBUS_API_BASE_URL:-https://api.ridebus.by/}
|
||||
RIDEBUS_API_KEY: ${RIDEBUS_API_KEY:?Set RIDEBUS_API_KEY in .env}
|
||||
ports:
|
||||
- "${PWA_BIND_ADDRESS:-127.0.0.1}:${PWA_PORT:-8080}:5173"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=16m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- --input-type=module
|
||||
- -e
|
||||
- "const response = await fetch('http://127.0.0.1:5173/healthz', { signal: AbortSignal.timeout(3000) }); process.exit(response.ok ? 0 : 1);"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
206
e2e/app.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('stop search, route filter and city-scoped favorites survive reload', async ({ page }, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/?demo=1');
|
||||
await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1');
|
||||
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
|
||||
await expect(page.locator('.stopItem')).toHaveCount(1);
|
||||
await page.locator('.stopItem').first().click();
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
const directions = page.getByRole('group', { name: 'Направления', exact: true });
|
||||
await expect(directions.getByRole('button')).toHaveCount(2);
|
||||
await directions.getByRole('button', { name: 'В сторону центра', exact: true }).click();
|
||||
await expect(page).toHaveURL(/stop=102/);
|
||||
await expect(directions.getByRole('button', { name: 'В сторону центра', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await directions.getByRole('button', { name: 'В сторону автовокзала', exact: true }).click();
|
||||
await expect(page).toHaveURL(/stop=101/);
|
||||
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
|
||||
await expect(page.locator('.departureRow')).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Все маршруты', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||||
if (info.project.name === 'mobile') {
|
||||
const mobileFavorite = page.getByRole('navigation', { name: 'Навигация на телефоне' }).getByRole('link', { name: /Избранное/ });
|
||||
await expect(mobileFavorite.locator('.mobileBadge')).toHaveText('1');
|
||||
await expect(mobileFavorite).toHaveText('1Избранное');
|
||||
const badge = await mobileFavorite.locator('.mobileBadge').boundingBox();
|
||||
const icon = await mobileFavorite.locator('.navIcon').boundingBox();
|
||||
expect(badge!.y).toBeLessThan(icon!.y);
|
||||
expect(badge!.x + badge!.width).toBeGreaterThan(icon!.x + icon!.width);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Закрыть уведомление', exact: true }).click();
|
||||
await page.screenshot({ path: `test-results/ridebus-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация', exact: true }).getByRole('link', { name: /Избранное/ }).click();
|
||||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||||
await expect(page.locator('.favoriteDepartures li')).toHaveCount(3);
|
||||
await page.reload();
|
||||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||||
await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click();
|
||||
await expect(page.locator('#board-title')).toHaveText('Площадь Свободы');
|
||||
await expect(page.getByRole('group', { name: 'Направления' }).getByRole('button', { name: 'В сторону автовокзала', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
|
||||
await expect(page.locator('#board-title')).toHaveText('Ваша остановка');
|
||||
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
|
||||
await page.getByRole('button', { name: /Площадь Строителей/ }).click();
|
||||
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
|
||||
await expect(page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true })).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('every grouped stop is visible without an eighty-stop cap', async ({ page }) => {
|
||||
await page.route('**/api/data?**', route => {
|
||||
const resource = new URL(route.request().url()).searchParams.get('resource');
|
||||
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }]
|
||||
: Array.from({ length: 115 }, (_, i) => ({ id: i + 1, name: `Остановка ${String(i + 1).padStart(3, '0')}`, direction: '', routesCount: 1 }));
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.stopItem')).toHaveCount(115);
|
||||
await expect(page.locator('.stopsFooter')).toContainText('115 остановок');
|
||||
await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('.stopItem').last()).toBeVisible();
|
||||
await expect(page.locator('main')).not.toContainText('Показаны первые');
|
||||
});
|
||||
|
||||
test('text is readable on narrow screens and contains no technical API wording', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
const tiny = await page.locator('main, footer, .mobileNav').evaluateAll(roots => roots.flatMap(root => [...root.querySelectorAll<HTMLElement>('p, label, strong, button, small, span')]).filter(element => {
|
||||
const visible = element.getClientRects().length > 0;
|
||||
return visible && element.textContent?.trim() && Number.parseFloat(getComputedStyle(element).fontSize) < 13 && !element.classList.contains('mobileBadge');
|
||||
}).map(element => ({ text: element.textContent, size: getComputedStyle(element).fontSize })));
|
||||
expect(tiny).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await expect(page.locator('main')).not.toContainText('API');
|
||||
await expect(page.locator('body')).not.toContainText('МЕНЬШЕ ОЖИДАНИЯ');
|
||||
await expect(page.getByRole('contentinfo')).toContainText('Расписание в вашем кармане');
|
||||
});
|
||||
|
||||
test('installed service worker serves shell, favorites and saved departures offline', async ({ page, context }) => {
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
|
||||
});
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await expect(page.locator('.offlineBanner')).toBeVisible();
|
||||
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('API failure is explicit and does not silently substitute demo data', async ({ page }) => {
|
||||
await page.route('**/api/data?**', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'api_not_configured' }) }));
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Подключаем расписание' })).toBeVisible();
|
||||
await expect(page.locator('.demoBanner')).toHaveCount(0);
|
||||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /Посмотреть пример/ })).toHaveCount(0);
|
||||
await expect(page.getByLabel('Остановки', { exact: true })).not.toContainText('на примере');
|
||||
await page.goto('/?demo=1');
|
||||
await expect(page.locator('.demoBanner')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a stale timetable loses expired departures across the date boundary', async ({ page, context }) => {
|
||||
await page.clock.install({ time: new Date('2026-10-06T20:59:00Z') });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await context.setOffline(true);
|
||||
await page.clock.fastForward('02:00:00');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||||
await expect(page.getByText('В сохранённых данных не осталось', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test('full timetable keeps past trips, switches date and direction, and restores its URL', async ({ page }, info) => {
|
||||
await page.clock.install({ time: new Date('2026-10-07T09:00:00Z') });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await page.getByRole('button', { name: 'На дату', exact: true }).click();
|
||||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||||
await expect(page.locator('.pastDeparture')).not.toHaveCount(0);
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-08');
|
||||
await expect(page.locator('.timetableSummary')).toContainText('8 октября');
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
|
||||
await expect(page.locator('.timetableSummary')).toContainText('8 рейсов');
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(8);
|
||||
await expect(page.locator('.hourRoute')).toHaveCount(0);
|
||||
await page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В сторону центра', exact: true }).click();
|
||||
await expect(page).toHaveURL(/stop=102/);
|
||||
await expect(page).toHaveURL(/date=2026-10-08/);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
|
||||
await expect(page.getByRole('button', { name: 'На дату', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||||
await page.screenshot({ path: `test-results/timetable-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await page.getByRole('button', { name: 'Ближайшие', exact: true }).click();
|
||||
await expect(page.locator('.timetableSummary')).toHaveCount(0);
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
});
|
||||
|
||||
test('saved full timetable works offline and does not substitute another date', async ({ page, context }) => {
|
||||
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
|
||||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
|
||||
});
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-09');
|
||||
await expect(page.getByRole('heading', { name: 'Нет сохранённого расписания', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('hours and minutes are consistent in departures and favorites, which expire offline', async ({ page, context }, info) => {
|
||||
const time = Date.parse('2026-10-07T09:00:00Z');
|
||||
await page.clock.install({ time: new Date(time) });
|
||||
await page.route('**/api/data?**', route => {
|
||||
const resource = new URL(route.request().url()).searchParams.get('resource');
|
||||
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }]
|
||||
: resource === 'stops' ? [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 1 }]
|
||||
: resource === 'alerts' ? [] : { routes: [{ id: 1, number: '112С', name: 'В центр' }], partial: false, updatedAt: new Date(time).toISOString(), departures: [{ routeId: 1, routeNumber: '112С', direction: 'В центр', scheduledTime: '14:01', scheduledAt: new Date(time + 121 * 60000).toISOString() }] };
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
await page.goto('/?city=1&stop=101');
|
||||
await expect(page.locator('.departureTime')).toContainText('через 2 ч 1 мин');
|
||||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
|
||||
await expect(page.locator('.favoriteDepartures')).toContainText('через 2 ч 1 мин');
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: `test-results/favorites-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await context.setOffline(true);
|
||||
await page.clock.fastForward('03:00:00');
|
||||
await expect(page.locator('.favoriteDepartures li')).toHaveCount(0);
|
||||
await expect(page.locator('.favoriteStatus')).toContainText('данные могут быть устаревшими');
|
||||
});
|
||||
|
||||
test('about page is accessible from the footer and fits a narrow screen', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/?demo=1');
|
||||
await page.getByRole('contentinfo').getByRole('link', { name: 'О проекте', exact: true }).click();
|
||||
await expect(page).toHaveURL('/about?demo=1');
|
||||
await expect(page.getByRole('heading', { name: 'Привет, я Захар.', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.aboutSupport')).toContainText('хостинг, домен');
|
||||
await expect(page.locator('.supportNote')).toContainText('Ссылку для поддержки добавлю чуть позже');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: `test-results/about-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await page.getByRole('link', { name: 'К расписанию', exact: true }).click();
|
||||
await expect(page).toHaveURL('/?demo=1');
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
195
e2e/features.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const time = Date.parse('2026-10-07T09:00:00.000Z');
|
||||
const routes = [{ id: 1, number: '112С', name: 'Вокзал → Центр' }, { id: 2, number: '10', name: 'Вокзал → Больница' }];
|
||||
const stops = [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 2 }, { id: 102, name: 'Вокзал', direction: 'В обратную сторону', routesCount: 1 }, { id: 103, name: 'Центр', direction: 'К вокзалу', routesCount: 1 }];
|
||||
async function mockLive(page: Page, extra: (resource: string, params: URLSearchParams) => unknown = () => undefined) {
|
||||
await page.route('**/api/data?**', route => {
|
||||
const params = new URL(route.request().url()).searchParams;
|
||||
const resource = params.get('resource')!;
|
||||
const data = extra(resource, params) ?? (resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }, { id: 2, name: 'Новополоцк', slug: 'novopolotsk' }]
|
||||
: resource === 'stops' ? stops : resource === 'alerts' ? [] : { routes, partial: false, updatedAt: new Date(time).toISOString(), departures: routes.map((item, index) => ({ routeId: item.id, routeNumber: item.number, direction: item.name, scheduledTime: `12:${10 + index * 10}`, scheduledAt: new Date(time + (10 + index * 10) * 60000).toISOString() })) });
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
}
|
||||
async function readyForOffline(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
|
||||
});
|
||||
}
|
||||
|
||||
test('saved journeys retain route/direction, allow rename/delete, and remain available offline', async ({ page, context }, info) => {
|
||||
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/?demo=1&city=1&stop=102');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сохранить поездку', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByLabel('Название поездки')).toBeFocused();
|
||||
await expect(dialog.getByLabel('Маршрут поездки')).toHaveValue('1');
|
||||
await dialog.getByRole('button', { name: 'На работу', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||||
await expect(page.locator('.tripDirection')).toHaveText('В сторону центра');
|
||||
await page.reload();
|
||||
await expect(page.locator('.quickTripHeading h3')).toHaveText('На работу');
|
||||
await page.getByRole('button', { name: 'Изменить', exact: true }).click();
|
||||
await page.getByRole('dialog').getByLabel('Название поездки').fill('Домой');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
|
||||
await page.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
|
||||
await expect(page).toHaveURL(/stop=102.*route=1/);
|
||||
await expect(page.getByRole('button', { name: 'Маршрут 4', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: `test-results/trips-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await readyForOffline(page);
|
||||
await context.setOffline(true); await page.reload();
|
||||
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
|
||||
await expect(page.locator('.tripSaved')).toContainText('могут быть устаревшими');
|
||||
await page.goto('/favorites?demo=1');
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||||
await context.setOffline(false);
|
||||
await page.goto('/favorites');
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(0);
|
||||
await page.goto('/favorites?demo=1');
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Удалить поездку Домой', exact: true }).click();
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('hour grid jumps to next departure and opens a cached trip with stop-by-stop times', async ({ page, context }, info) => {
|
||||
await page.clock.install({ time: new Date(time) });
|
||||
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await page.getByRole('button', { name: 'К ближайшему', exact: true }).click();
|
||||
const next = page.locator('.nextDeparture');
|
||||
await expect(next).toBeFocused();
|
||||
await expect(next).toContainText('12:00');
|
||||
await next.click();
|
||||
await expect(page).toHaveURL(/\/route\?/);
|
||||
await expect(page.locator('.journeyTimeline li')).toHaveCount(6);
|
||||
await expect(page.locator('.selectedJourneyStop .journeyTime')).toHaveText('12:00');
|
||||
await expect(page.locator('.journeyTime strong')).toHaveText(['12:00', '12:05', '12:10', '12:15', '12:20', '12:25']);
|
||||
const journeyUrl = page.url();
|
||||
await page.screenshot({ path: `test-results/journey-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
await page.getByRole('link', { name: 'К расписанию остановки', exact: true }).click();
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await expect(page.getByLabel('Дата поездки')).toHaveValue('2026-10-07');
|
||||
await readyForOffline(page); await context.setOffline(true);
|
||||
await page.goto(journeyUrl); await page.reload();
|
||||
await expect(page.locator('.journeyTimeline li')).toHaveCount(6);
|
||||
await expect(page.locator('.routeTimelineCard .savedNotice')).toContainText('Сохранено');
|
||||
await page.locator('.journeyTimeline a').nth(1).click();
|
||||
await expect(page).toHaveURL(/stop=102/);
|
||||
await expect(page).toHaveURL(/date=2026-10-07/);
|
||||
});
|
||||
|
||||
test('save dialog fits narrow screens, closes with Escape, and restores button focus', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
const open = page.getByRole('button', { name: 'Сохранить поездку', exact: true });
|
||||
await open.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
const bounds = await page.getByRole('dialog').boundingBox();
|
||||
expect(bounds!.width).toBeLessThanOrEqual(320);
|
||||
await page.screenshot({ path: `test-results/trip-dialog-${info.project.name}.png`, animations: 'disabled' });
|
||||
const work = page.getByRole('dialog').getByRole('button', { name: 'На работу', exact: true });
|
||||
await work.focus(); await work.press('Enter');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByLabel('Название поездки')).toHaveValue('На работу');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(open).toBeFocused();
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); await open.click();
|
||||
expect(await page.getByRole('dialog').evaluate(element => getComputedStyle(element).animationName)).toBe('none');
|
||||
});
|
||||
|
||||
test('nearby search requests location only on click, groups stops, and never persists coordinates', async ({ page, context }) => {
|
||||
await context.grantPermissions(['geolocation']);
|
||||
await context.setGeolocation({ latitude: 55.49, longitude: 28.783, accuracy: 25 });
|
||||
let searches = 0;
|
||||
await mockLive(page, (resource, params) => {
|
||||
if (resource !== 'nearby') return;
|
||||
searches++; expect(params.get('lat')).toBe('55.49000'); expect(params.get('lon')).toBe('28.78300');
|
||||
return [{ ...stops[0], distance: 120 }, { ...stops[1], distance: 75 }, { ...stops[2], distance: 250 }];
|
||||
});
|
||||
await page.goto('/?city=1');
|
||||
await expect(page.locator('.stopItem')).toHaveCount(2);
|
||||
expect(searches).toBe(0);
|
||||
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
|
||||
await expect(page.locator('.nearbyResults button')).toHaveCount(2);
|
||||
await expect(page.locator('.nearbyResults button').first()).toHaveText('Вокзал75 м');
|
||||
await page.locator('.nearbyResults button').first().click();
|
||||
await expect(page).toHaveURL(/stop=102/);
|
||||
await expect(page.locator('#board-title')).toHaveText('Вокзал');
|
||||
await expect(page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В обратную сторону', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(searches).toBe(1);
|
||||
const persisted = await page.evaluate(async () => {
|
||||
const request = indexedDB.open('ridebus-pwa');
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
const keys = database.transaction('snapshots').objectStore('snapshots').getAllKeys();
|
||||
const result = await new Promise<IDBValidKey[]>(resolve => { keys.onsuccess = () => resolve(keys.result); });
|
||||
database.close(); return JSON.stringify({ keys: result, preferences: { ...localStorage } });
|
||||
});
|
||||
expect(persisted).not.toContain('nearby'); expect(persisted).not.toContain('55.49'); expect(persisted).not.toContain('28.783');
|
||||
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
|
||||
await expect(page.locator('.nearbyResults')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('location denial leaves manual search usable', async ({ page }) => {
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'geolocation', { value: { getCurrentPosition: (_success: unknown, failure: (error: { code: number }) => void) => failure({ code: 1 }) } }));
|
||||
await page.goto('/?demo=1&city=1');
|
||||
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
|
||||
await expect(page.locator('.nearbyMessage')).toContainText('Доступ к местоположению закрыт');
|
||||
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Больница');
|
||||
await page.locator('.stopItem').click();
|
||||
await expect(page.locator('#board-title')).toHaveText('Больница');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
});
|
||||
|
||||
test('targeted cancellation preserves details, filters by route, and expires offline', async ({ page, context }) => {
|
||||
await page.clock.install({ time: new Date(time) });
|
||||
const alert = { id: 1, cityId: 1, title: 'Отмена рейсов', message: 'Вокзал 12:10 — отменён\nЦентр 12:25 — отменён', type: 'cancellation', routeNumbers: ['112С', '27'], routeIds: [1], isCityWide: false, startTime: new Date(time).toISOString(), endTime: new Date(time + 3600000).toISOString() };
|
||||
await mockLive(page, resource => resource === 'alerts' ? [alert, alert, { ...alert, id: 2, cityId: 2 }, { ...alert, id: 3, endTime: new Date(time - 1).toISOString() }] : undefined);
|
||||
await page.goto('/?city=1&stop=101');
|
||||
await expect(page.locator('.serviceAlert')).toHaveCount(1);
|
||||
await expect(page.locator('.alertRoutes')).toHaveText('№112С · №27');
|
||||
await expect(page.locator('.serviceAlert p')).toHaveText(alert.message);
|
||||
await page.getByRole('button', { name: 'Маршрут 10', exact: true }).click();
|
||||
await expect(page.locator('.serviceAlert')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Маршрут 112С', exact: true }).click();
|
||||
await expect(page.locator('.serviceAlert')).toHaveCount(1);
|
||||
await context.setOffline(true);
|
||||
await expect(page.locator('.alertSaved')).toBeVisible();
|
||||
await page.clock.fastForward(3600001);
|
||||
await expect(page.locator('.serviceAlert')).toHaveCount(0);
|
||||
await expect(page.locator('.alertsUnavailable')).toBeVisible();
|
||||
});
|
||||
|
||||
test('storage upgrade preserves existing favorites while adding journeys', async ({ page }) => {
|
||||
await page.addInitScript(async () => {
|
||||
// Dexie version 1 uses IndexedDB version 10.
|
||||
const request = indexedDB.open('ridebus-pwa', 10);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
database.createObjectStore('snapshots', { keyPath: 'key' });
|
||||
const favorites = database.createObjectStore('favorites', { keyPath: 'key' });
|
||||
favorites.createIndex('cityId', 'cityId'); favorites.createIndex('mode', 'mode');
|
||||
favorites.put({ key: 'demo:1:101', mode: 'demo', cityId: 1, cityName: 'Полоцк', id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4, addedAt: 1 });
|
||||
};
|
||||
request.onsuccess = () => request.result.close();
|
||||
});
|
||||
await page.goto('/favorites?demo=1');
|
||||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||||
await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||||
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
|
||||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||||
});
|
||||
200
e2e/interaction.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
async function seedTrips(page: Page, count: number) {
|
||||
await page.evaluate(async count => {
|
||||
const request = indexedDB.open('ridebus-pwa');
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
const transaction = database.transaction('quickTrips', 'readwrite');
|
||||
const store = transaction.objectStore('quickTrips');
|
||||
store.clear();
|
||||
for (let index = 0; index < count; index++) {
|
||||
const otherCity = index === 2;
|
||||
store.put({ id: `test-trip-${index}`, name: ['Домой', 'На работу', 'К родителям', 'В гости'][index], mode: 'demo', cityId: otherCity ? 2 : 1, cityName: otherCity ? 'Новополоцк' : 'Полоцк',
|
||||
stop: { id: otherCity ? 201 : 101, name: otherCity ? 'Площадь Строителей' : 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 },
|
||||
route: { id: index + 1, number: ['4', '10', '12', '24'][index], name: ['Автовокзал → Больница', 'Вокзал → Аэропорт', 'Боровуха → Автовокзал', 'Центр → Университет'][index] }, createdAt: index + 1 });
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); });
|
||||
database.close();
|
||||
}, count);
|
||||
// Native IndexedDB fixture writes are read when the application reloads.
|
||||
await page.reload();
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(count);
|
||||
}
|
||||
|
||||
test('journeys use a mobile carousel and switch desktop layout at three cards', async ({ page }, info) => {
|
||||
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await seedTrips(page, 2);
|
||||
if (info.project.name === 'mobile') await expect(page.locator('.tripsCarouselTrack')).toHaveCount(1);
|
||||
else await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
|
||||
await seedTrips(page, 3);
|
||||
const track = page.getByRole('region', { name: 'Сохранённые поездки', exact: true });
|
||||
await expect(track).toBeVisible();
|
||||
await expect(track).toHaveAttribute('data-fade-start', 'false');
|
||||
await expect(track).toHaveAttribute('data-fade-end', 'true');
|
||||
await expect(track).toHaveCSS('mask-image', /linear-gradient/);
|
||||
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-fade-${info.project.name}.png`, animations: 'disabled' });
|
||||
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Следующая поездка' }).click();
|
||||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
|
||||
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeEnabled();
|
||||
await track.focus(); await track.press('Home');
|
||||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeLessThan(3);
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
await expect(track).toHaveCSS('scroll-snap-type', 'x mandatory');
|
||||
await track.focus(); await track.press('End');
|
||||
await expect(page.locator('.carouselControls > span')).toHaveText('3 из 3');
|
||||
await expect(page.getByRole('button', { name: 'Следующая поездка' })).toBeDisabled();
|
||||
await expect(track).toHaveAttribute('data-fade-start', 'true');
|
||||
await expect(track).toHaveAttribute('data-fade-end', 'false');
|
||||
await expect.poll(() => track.evaluate(element => element.clientHeight - (element.children[2] as HTMLElement).offsetHeight)).toBeLessThan(30);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-${info.project.name}.png`, animations: 'disabled' });
|
||||
await page.getByRole('button', { name: 'Удалить поездку К родителям', exact: true }).click();
|
||||
await expect(page.locator('.quickTripCard')).toHaveCount(2);
|
||||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||||
await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('touch swipe moves the carousel without moving the whole page sideways', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await seedTrips(page, 3);
|
||||
const track = page.locator('.tripsCarouselTrack');
|
||||
await track.scrollIntoViewIfNeeded();
|
||||
const bounds = await track.boundingBox();
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
const start = bounds!.x + bounds!.width - 25;
|
||||
const y = Math.min(600, bounds!.y + 110);
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: start, y }] });
|
||||
for (let step = 1; step <= 8; step++) await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start - step * 28, y }] });
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await session.detach();
|
||||
});
|
||||
|
||||
test('opening a saved journey scrolls to the correct board across cities and from favorites', async ({ page }) => {
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await seedTrips(page, 3);
|
||||
await page.addInitScript(() => {
|
||||
const original = HTMLElement.prototype.scrollIntoView;
|
||||
(window as any).boardScrolls = [];
|
||||
HTMLElement.prototype.scrollIntoView = function(options) {
|
||||
if (this.id === 'departures') (window as any).boardScrolls.push(options);
|
||||
original.call(this, options);
|
||||
};
|
||||
});
|
||||
await page.goto('/favorites?demo=1');
|
||||
const card = page.locator('.quickTripCard').filter({ has: page.getByRole('heading', { name: 'К родителям', exact: true }) });
|
||||
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
|
||||
await expect(page).toHaveURL(/city=2.*stop=201.*route=3/);
|
||||
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
|
||||
await expect(page.locator('#departures')).toBeFocused();
|
||||
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('smooth');
|
||||
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(350);
|
||||
await expect(page.getByRole('button', { name: 'Маршрут 12', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('auto');
|
||||
await expect(page.locator('#departures')).toBeFocused();
|
||||
});
|
||||
|
||||
test('native sharing carries the exact departure and does not navigate or copy unnecessarily', async ({ page }) => {
|
||||
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
|
||||
await page.addInitScript(() => {
|
||||
(window as any).shared = []; (window as any).copied = [];
|
||||
Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { (window as any).shared.push(data); } });
|
||||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
|
||||
});
|
||||
await page.goto('/?demo=1&city=1&stop=101&extra=private');
|
||||
const originalLink = await page.locator('.departureEntry').first().getByRole('link').getAttribute('href');
|
||||
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
|
||||
const shared = await page.evaluate(() => (window as any).shared);
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0].text).toContain('Площадь Свободы'); expect(shared[0].text).toContain('12:04');
|
||||
const params = new URL(shared[0].url).searchParams;
|
||||
expect(params.get('city')).toBe('1'); expect(params.get('stop')).toBe('101'); expect(params.get('route')).toBe('2');
|
||||
expect(params.get('at')).toBe(new URL(originalLink!, 'http://localhost').searchParams.get('at')); expect(params.get('extra')).toBeNull();
|
||||
expect(await page.evaluate(() => (window as any).copied.length)).toBe(0);
|
||||
await expect(page).toHaveURL(/extra=private/);
|
||||
});
|
||||
|
||||
test('sharing cancellation stays silent and leaves clipboard unchanged', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as any).copies = 0;
|
||||
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Cancelled', 'AbortError'); } });
|
||||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { (window as any).copies++; } } });
|
||||
});
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
const button = page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ });
|
||||
await button.click(); await expect(button).toBeEnabled();
|
||||
expect(await page.evaluate(() => (window as any).copies)).toBe(0);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.locator('.toast')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('copy fallback opens the same trip and works from the hourly timetable route screen', async ({ page }) => {
|
||||
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
|
||||
await page.addInitScript(() => {
|
||||
(window as any).copied = [];
|
||||
Object.defineProperty(navigator, 'share', { configurable: true, value: undefined });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
|
||||
});
|
||||
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
|
||||
await page.locator('.hourDeparture').filter({ hasText: '12:00' }).click();
|
||||
const button = page.getByRole('button', { name: 'Поделиться отправлением', exact: true });
|
||||
await button.click();
|
||||
await expect(page.locator('.toast')).toContainText('Ссылка на отправление скопирована');
|
||||
const url = await page.evaluate(() => (window as any).copied[0]);
|
||||
await page.goto(url);
|
||||
await expect(page.locator('.selectedJourneyStop .journeyTime strong')).toHaveText('12:00');
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await button.click();
|
||||
expect(await page.evaluate(() => (window as any).copied[0])).toBe(url);
|
||||
});
|
||||
|
||||
test('blocked native sharing and clipboard offer a selectable manual link', async ({ page }, info) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } });
|
||||
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } } });
|
||||
});
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Поделиться отправлением', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByLabel('Ссылка на отправление');
|
||||
await expect(input).toBeFocused();
|
||||
expect(await input.evaluate((element: HTMLInputElement) => element.selectionEnd! - element.selectionStart!)).toBe((await input.inputValue()).length);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: `test-results/share-link-${info.project.name}.png`, animations: 'disabled' });
|
||||
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('nearby and all stops share rows, selection, typography and scrollbar styles', async ({ page, context }, info) => {
|
||||
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783 });
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
|
||||
const nearby = page.getByLabel('Остановки рядом', { exact: true });
|
||||
const all = page.getByLabel('Остановки', { exact: true });
|
||||
await expect(nearby.locator('.stopItem')).toHaveCount(3);
|
||||
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
|
||||
await nearby.getByRole('button', { name: /Больница/ }).click();
|
||||
await expect(all.locator('.activeStop')).toHaveText('Больница');
|
||||
await expect(nearby.locator('.activeStop')).toContainText('Больница');
|
||||
const styles = (element: HTMLElement) => {
|
||||
const list = getComputedStyle(element), row = getComputedStyle(element.querySelector('.stopItem')!), name = getComputedStyle(element.querySelector('.stopCopy strong')!);
|
||||
return { scrollbarWidth: list.scrollbarWidth, scrollbarColor: list.scrollbarColor, maxHeight: list.maxHeight, padding: row.padding, radius: row.borderRadius, minHeight: row.minHeight, font: name.fontSize, weight: name.fontWeight };
|
||||
};
|
||||
expect(await nearby.evaluate(styles)).toEqual(await all.evaluate(styles));
|
||||
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
|
||||
});
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f3f3fa" />
|
||||
<meta name="description" content="Расписание общественного транспорта RideBus. Найдите остановку и сохраните её в избранное." />
|
||||
<title>RideBus — расписание в вашем кармане</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7928
package-lock.json
generated
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "ridebus-pwa",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --env-file-if-exists=.env server/index.mjs --dev",
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node --env-file-if-exists=.env server/index.mjs",
|
||||
"preview": "npm run start",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc -b",
|
||||
"icons": "node scripts/generate-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"@vkontakte/icons": "^3.75.0",
|
||||
"dexie": "^4.4.6",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"sass": "^1.105.1",
|
||||
"sharp": "^0.35.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3",
|
||||
"vite-plugin-pwa": "^2.0.0"
|
||||
}
|
||||
}
|
||||
15
playwright.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH ??= './.playwright';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
fullyParallel: false,
|
||||
use: { baseURL: 'http://127.0.0.1:5173', trace: 'retain-on-failure' },
|
||||
projects: [
|
||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 1080 } } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' } },
|
||||
],
|
||||
webServer: { command: 'npm start', url: 'http://127.0.0.1:5173', reuseExistingServer: true },
|
||||
});
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icon-maskable.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
37
public/logo.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 59 58" style="enable-background:new 0 0 59 58;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_2_);}
|
||||
.st1{fill:#455E91;}
|
||||
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st3{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="0.5" width="58" height="58"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g class="st0">
|
||||
<path class="st1" d="M21.9,1.3l19.7,1.6C51,3.6,58,11.8,57.2,21.2l-1.6,19.7c-0.7,9.4-8.9,16.3-18.3,15.6l-19.7-1.6
|
||||
C8.4,54.2,1.4,46,2.1,36.6l1.6-19.7C4.4,7.6,12.6,0.6,21.9,1.3z"/>
|
||||
<path class="st2" d="M23.6,38.6C0.3,23.3,37,15.1,40.3,16 M23.6,38.6C46.1,53.4,41.7,19.3,40.3,16 M23.6,38.6
|
||||
c10.2,3,15.1-17,16.7-22.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="path" class="st3" d="M20.3,44.4c-1.6,0-2.2-0.5-2.3-1.7c0-0.8,0-1.6,0-2.4c0-0.2-0.1-0.3-0.2-0.5c-1-1.2-1.6-2.7-1.5-4.2
|
||||
c0.1-5.1,0-10.1,0-15.2c0-0.8,0.1-1.6,0.3-2.3c0.6-1.9,2-3,3.8-3.5c2.1-0.7,4.3-1,6.5-0.9c2.7,0,5.5,0,8.2,0.2
|
||||
c2.1,0.2,4.2,0.7,5.8,2.3c0.3,0.2,0.5,0.5,0.6,0.8c0.8,1.3,1.2,3,1,4.5c0,2.3,0,4.6-0.1,6.8l0,0c0,2.6,0,5.1,0,7.8
|
||||
c0,1.3-0.5,2.7-1.4,3.7c-0.3,0.3-0.3,0.6-0.3,0.9c0,0.8,0,1.5,0,2.1c0.1,0.6-0.3,1.2-0.8,1.5c-0.2,0.2-0.5,0.3-0.8,0.3
|
||||
c-0.6,0-1.2,0-1.9,0c-0.9,0-1.6-0.7-1.7-1.6c0-0.4,0-0.8,0-1.2c0-0.2-0.2-0.3-0.3-0.4l0,0H23.3c-0.3,0-0.4,0.1-0.3,0.4
|
||||
c0,0.3,0,0.8,0,1.1c0,0.9-0.8,1.7-1.6,1.7 M29.4,20.2L29.4,20.2c-3.1,0-6.2,0-9.2,0c-0.3,0-0.5,0.1-0.5,0.5c0,2.3,0,4.6,0,7
|
||||
c0,0.3,0.1,0.5,0.5,0.5c6.2,0,12.2,0,18.4,0c0.4,0,0.5-0.1,0.5-0.5c0-2.3,0-4.6,0-6.8c0-0.3-0.3-0.6-0.6-0.6l0,0
|
||||
C35.5,20.2,32.5,20.2,29.4,20.2z M37,37.9c1.3-0.2,2.3-1.3,2.1-2.6c-0.2-1.3-1.3-2.3-2.6-2.1c-0.2,0-0.3,0.1-0.5,0.1
|
||||
c-1.3,0.4-1.9,1.8-1.5,3C34.9,37.3,35.9,38,37,37.9L37,37.9z M21.7,37.9c1.3,0.2,2.5-0.6,2.7-1.9c0.2-1.3-0.6-2.5-1.9-2.7
|
||||
c-1.2-0.2-2.5,0.6-2.7,1.9c-0.1,0.2-0.1,0.3-0.1,0.5C19.8,36.8,20.7,37.7,21.7,37.9L21.7,37.9z M26.6,36.2c-0.1,1,0.5,2,1.4,2.4
|
||||
c0.9,0.5,2.1,0.3,3-0.2c0.8-0.5,1.2-1.3,1.1-2.2h-1.2c-0.3,1-0.8,1.5-1.6,1.5c-0.8,0-1.3-0.4-1.6-1.5C27.8,36.2,26.6,36.2,26.6,36.2
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
3
public/star.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="290" height="290" fill="none" viewBox="0 0 290 290">
|
||||
<path fill="currentColor" d="M110 43.6a46 46 0 0 1 70.7 0l16.7 20a46 46 0 0 0 19.4 13.8l22.8 8.4a46 46 0 0 1 22.2 68.9L249.4 173a46 46 0 0 0-7.8 23.3l-1.2 23.4a46 46 0 0 1-57.6 42L157 255a46 46 0 0 0-23.3 0l-25.8 6.8a46 46 0 0 1-57.6-42.1L49 196.4a46 46 0 0 0-7.8-23.3l-12.4-18.4A46 46 0 0 1 51 85.8l22.8-8.4a46 46 0 0 0 19.4-13.7l16.7-20Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
BIN
qa/carousel-fade-desktop.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
qa/carousel-fade-mobile.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
qa/carousel-live-desktop.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
qa/carousel-live-mobile.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
qa/carousel-narrow-mobile.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
qa/live-hour-grid-mobile.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
qa/live-night-trip-mobile.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
qa/live-saved-trip-mobile.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
qa/shared-departure-live-mobile.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
qa/unified-stops-live-desktop.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
10
scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import sharp from 'sharp';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
const brand = (await readFile(new URL('../public/logo.svg', import.meta.url), 'utf8'))
|
||||
.replace(/<\?xml[^>]*\?>/, '').replace(/<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||
for (const [name, size, inset] of [['icon-192.png', 192, 15], ['icon-512.png', 512, 15], ['icon-maskable.png', 512, 25], ['apple-touch-icon.png', 180, 15]]) {
|
||||
const side = 100 - inset * 2;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100"><rect width="100" height="100" fill="#f3f3fa"/><g transform="translate(${inset} ${inset}) scale(${side / 59})">${brand}</g></svg>`;
|
||||
await sharp(Buffer.from(svg)).resize(size, size).png().toFile(new URL(`../public/${name}`, import.meta.url).pathname);
|
||||
}
|
||||
console.log('RideBus PWA icons generated');
|
||||
7
server/errors.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code = 'data_unavailable') {
|
||||
super(code);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
37
server/features.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ApiError } from './errors.mjs';
|
||||
|
||||
const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : '';
|
||||
const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0;
|
||||
|
||||
export function normalizeAlerts(raw, cityId) {
|
||||
if (!Array.isArray(raw)) throw new ApiError(502);
|
||||
const seen = new Set();
|
||||
return raw.flatMap(alert => {
|
||||
if (!alert || !positive(alert.id)) throw new ApiError(502);
|
||||
if (alert.is_active === false || (alert.city_id != null && Number(alert.city_id) !== cityId) || seen.has(Number(alert.id))) return [];
|
||||
if ((alert.route_numbers != null && (!Array.isArray(alert.route_numbers) || alert.route_numbers.some(value => !routeNumber(value))))
|
||||
|| (alert.route_ids != null && (!Array.isArray(alert.route_ids) || alert.route_ids.some(value => !positive(value))))) throw new ApiError(502);
|
||||
const numbers = Array.isArray(alert.route_numbers) ? alert.route_numbers.map(routeNumber).filter(Boolean) : [];
|
||||
const ids = Array.isArray(alert.route_ids) ? alert.route_ids.filter(positive).map(Number) : [];
|
||||
if (!numbers.length && routeNumber(alert.route_number)) numbers.push(routeNumber(alert.route_number));
|
||||
if (!ids.length && positive(alert.route_id)) ids.push(Number(alert.route_id));
|
||||
const startTime = Date.parse(alert.start_time);
|
||||
const endTime = alert.end_time == null ? null : Date.parse(alert.end_time);
|
||||
if (!Number.isFinite(startTime) || (endTime !== null && !Number.isFinite(endTime)) || typeof alert.title !== 'string' || typeof alert.message !== 'string') throw new ApiError(502);
|
||||
seen.add(Number(alert.id));
|
||||
return [{ id: Number(alert.id), cityId, title: alert.title.trim(), message: alert.message.trim(),
|
||||
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
|
||||
routeNumbers: [...new Set(numbers)].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true })), routeIds: [...new Set(ids)], isCityWide: !numbers.length && !ids.length,
|
||||
startTime: new Date(startTime).toISOString(), endTime: endTime === null ? null : new Date(endTime).toISOString() }];
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeNearby(raw) {
|
||||
if (!Array.isArray(raw)) throw new ApiError(502);
|
||||
return raw.map(stop => {
|
||||
const distance = Number(stop?.distance_meters);
|
||||
if (!stop || !positive(stop.id) || typeof stop.name !== 'string' || !stop.name.trim() || stop.distance_meters == null || !Number.isFinite(distance) || distance < 0) throw new ApiError(502);
|
||||
return { id: Number(stop.id), name: stop.name.trim(), direction: typeof stop.description === 'string' ? stop.description.trim() : '',
|
||||
routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance) };
|
||||
}).sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
84
server/index.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import http from 'node:http';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ApiError, createRideBusClient, parseRequest } from './ridebus.mjs';
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const dist = resolve(root, 'dist');
|
||||
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.webmanifest': 'application/manifest+json' };
|
||||
|
||||
export function apiHandler(client = createRideBusClient()) {
|
||||
return async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (!url.pathname.startsWith('/api/')) return false;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', 'GET');
|
||||
res.writeHead(405).end(JSON.stringify({ error: 'method_not_allowed' }));
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (url.pathname !== '/api/data') throw new ApiError(404, 'not_found');
|
||||
const data = await client.get(parseRequest(url.searchParams));
|
||||
res.writeHead(200).end(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
const known = error instanceof ApiError;
|
||||
// Upstream bodies, keys and configuration never reach the browser.
|
||||
res.writeHead(known ? error.status : 502).end(JSON.stringify({ error: known ? error.code : 'data_unavailable' }));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const dev = process.argv.includes('--dev');
|
||||
const port = Number(process.env.PORT || 5173);
|
||||
const host = process.env.HOST || '127.0.0.1';
|
||||
if (!dev && !existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build before npm start');
|
||||
const api = apiHandler();
|
||||
const vite = dev ? await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' }) : null;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (new URL(req.url, 'http://localhost').pathname === '/healthz') {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
res.writeHead(405).end();
|
||||
} else {
|
||||
res.writeHead(200).end(req.method === 'HEAD' ? undefined : JSON.stringify({ status: 'ok' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await api(req, res)) return;
|
||||
if (vite) { vite.middlewares(req, res); return; }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end(); return; }
|
||||
let path;
|
||||
try { path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); } catch { res.writeHead(400).end(); return; }
|
||||
let file = resolve(dist, `.${path}`);
|
||||
if (file !== dist && !file.startsWith(dist + sep)) { res.writeHead(403).end(); return; }
|
||||
if (!existsSync(file) || !statSync(file).isFile()) {
|
||||
if (extname(path)) { res.writeHead(404).end(); return; }
|
||||
file = resolve(dist, 'index.html');
|
||||
}
|
||||
res.setHeader('Content-Type', mime[extname(file)] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', file.includes(`${sep}assets${sep}`) ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (req.method === 'HEAD') { res.end(); return; }
|
||||
createReadStream(file).pipe(res);
|
||||
});
|
||||
server.on('error', error => {
|
||||
console.error(error.code === 'EADDRINUSE' ? `Port ${port} is already in use. Set PORT in .env.` : `Unable to start server: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
server.listen(port, host, () => {
|
||||
console.log(`RideBus ${dev ? 'development' : 'PWA'}: http://${host}:${port}`);
|
||||
console.log(`RideBus API: ${process.env.RIDEBUS_API_KEY ? 'configured' : 'add RIDEBUS_API_KEY to .env'}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
start().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
}
|
||||
236
server/ridebus.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
import { ApiError } from './errors.mjs';
|
||||
import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs';
|
||||
import { normalizeAlerts, normalizeNearby } from './features.mjs';
|
||||
export { ApiError } from './errors.mjs';
|
||||
|
||||
function positiveId(value) {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
||||
throw new ApiError(400, 'invalid_request');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function parseRequest(params) {
|
||||
const resource = params.get('resource');
|
||||
if (resource === 'cities') return { resource };
|
||||
if (!['stops', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request');
|
||||
const cityId = positiveId(params.get('cityId'));
|
||||
if (resource === 'alerts') return { resource, cityId };
|
||||
if (resource === 'nearby') {
|
||||
const coordinate = (name, limit) => {
|
||||
const value = params.get(name);
|
||||
if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request');
|
||||
return Number(value);
|
||||
};
|
||||
const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000;
|
||||
if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request');
|
||||
return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius };
|
||||
}
|
||||
if (resource === 'journey') {
|
||||
const at = params.get('at');
|
||||
const time = Date.parse(at);
|
||||
if (!Number.isFinite(time) || new Date(time).toISOString() !== at) throw new ApiError(400, 'invalid_request');
|
||||
const date = new Date(time + 10800000).toISOString().slice(0, 10);
|
||||
if (date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
|
||||
const tripKey = params.get('tripKey') || undefined;
|
||||
if (tripKey && (!/^[\w-]+$/.test(tripKey) || tripKey.length > 2000)) throw new ApiError(400, 'invalid_request');
|
||||
return { resource, cityId, stopId: positiveId(params.get('stopId')), routeId: positiveId(params.get('routeId')), at, ...(tripKey ? { tripKey } : {}) };
|
||||
}
|
||||
if (resource === 'timetable') {
|
||||
const date = params.get('date');
|
||||
if (!validDate(date) || date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
|
||||
return { resource, cityId, stopId: positiveId(params.get('stopId')), date };
|
||||
}
|
||||
return resource === 'stops' ? { resource, cityId } : { resource, cityId, stopId: positiveId(params.get('stopId')) };
|
||||
}
|
||||
|
||||
const record = value => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ApiError(502);
|
||||
return value;
|
||||
};
|
||||
const list = value => {
|
||||
if (!Array.isArray(value)) throw new ApiError(502);
|
||||
return value;
|
||||
};
|
||||
const text = value => {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new ApiError(502);
|
||||
return value.trim();
|
||||
};
|
||||
const id = value => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result) || result <= 0) throw new ApiError(502);
|
||||
return result;
|
||||
};
|
||||
|
||||
export function normalizeCities(raw) {
|
||||
return list(raw).filter(item => item?.is_active !== false).map(item => {
|
||||
const city = record(item);
|
||||
return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '' };
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeStops(raw) {
|
||||
return list(raw).map(item => {
|
||||
const stop = record(item);
|
||||
return {
|
||||
id: id(stop.id), name: text(stop.name),
|
||||
direction: typeof stop.description === 'string' ? stop.description.trim() : '',
|
||||
routesCount: Math.max(0, Number(stop.routes_count) || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDepartures(raw, now = Date.now()) {
|
||||
const departures = [];
|
||||
const routes = [];
|
||||
let partial = false;
|
||||
for (const item of list(record(raw).routes)) {
|
||||
const route = record(item);
|
||||
const routeInfo = { id: id(route.id), number: text(route.route_number), name: text(route.name) };
|
||||
routes.push(routeInfo);
|
||||
if (route.error) { partial = true; continue; }
|
||||
for (const value of list(route.arrivals)) {
|
||||
const arrival = record(value);
|
||||
const time = typeof arrival.scheduledTime === 'string' && /^(\d{1,2}):([0-5]\d)(?::[0-5]\d)?$/.exec(arrival.scheduledTime);
|
||||
// RideBus ETA includes historical delay; this view explicitly shows the timetable.
|
||||
const minutes = Number(arrival.minutesUntil) - Number(arrival.delay ?? 0);
|
||||
if (!time || Number(time[1]) > 23 || arrival.minutesUntil == null || !Number.isFinite(minutes)) {
|
||||
partial = true; continue;
|
||||
}
|
||||
if (minutes < 0 || minutes >= 1440) continue;
|
||||
const scheduledAt = new Date(now + minutes * 60000).toISOString();
|
||||
let serviceDate = new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(0, 10);
|
||||
const originTime = typeof arrival.trip?.departureTime === 'string' && /^(\d{1,2}):([0-5]\d)/.exec(arrival.trip.departureTime);
|
||||
if (originTime && Number(time[1]) * 60 + Number(time[2]) < Number(originTime[1]) * 60 + Number(originTime[2])) serviceDate = previousDate(serviceDate);
|
||||
departures.push({
|
||||
routeId: routeInfo.id, routeNumber: routeInfo.number, direction: routeInfo.name,
|
||||
scheduledTime: `${time[1].padStart(2, '0')}:${time[2]}`,
|
||||
// Use an absolute instant so old snapshots expire, including across midnight.
|
||||
scheduledAt,
|
||||
...(arrival.trip ? { tripKey: tripIdentity(arrival.trip, serviceDate) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
|
||||
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
|
||||
updatedAt: new Date(now).toISOString(), partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRideBusClient({
|
||||
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/',
|
||||
apiKey = process.env.RIDEBUS_API_KEY,
|
||||
fetchData = fetch,
|
||||
now = Date.now,
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const pending = new Map();
|
||||
const rawCache = new Map();
|
||||
const rawPending = new Map();
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
async function upstream(path, cityId, allowMissing = false) {
|
||||
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
|
||||
if (active >= 6) await new Promise(resolve => queue.push(resolve));
|
||||
else active++;
|
||||
const headers = { Accept: 'application/json', 'X-API-Key': apiKey };
|
||||
if (cityId) headers['X-City-Id'] = String(cityId);
|
||||
try {
|
||||
const response = await fetchData(new URL(path, `${baseUrl.replace(/\/+$/, '')}/`), {
|
||||
headers, redirect: 'error', signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (allowMissing && response.status === 404) return null;
|
||||
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
|
||||
return await response.json();
|
||||
} catch (error) { throw error instanceof ApiError ? error : new ApiError(502); }
|
||||
finally {
|
||||
const next = queue.shift();
|
||||
if (next) next(); else active--;
|
||||
}
|
||||
}
|
||||
async function cachedUpstream(path, cityId, allowMissing = false) {
|
||||
const key = `${cityId}:${path}`;
|
||||
const entry = rawCache.get(key);
|
||||
if (entry && entry.expires > now()) return entry.data;
|
||||
if (rawPending.has(key)) return rawPending.get(key);
|
||||
const request = upstream(path, cityId, allowMissing).then(data => {
|
||||
if (rawCache.size >= 200) rawCache.delete(rawCache.keys().next().value);
|
||||
rawCache.set(key, { data, expires: now() + 300000 });
|
||||
return data;
|
||||
}).finally(() => rawPending.delete(key));
|
||||
rawPending.set(key, request);
|
||||
return request;
|
||||
}
|
||||
async function timetable(input) {
|
||||
const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId));
|
||||
const routes = list(stop.routes).map(route => ({ id: id(route.id), number: text(route.route_number), name: text(route.name) }));
|
||||
const dates = [previousDate(input.date), input.date];
|
||||
const results = await Promise.allSettled(routes.map(async route => {
|
||||
const [stops, ...schedules] = await Promise.all([
|
||||
cachedUpstream(`api/v1/routes/${route.id}/stops`, input.cityId),
|
||||
...dates.map(date => cachedUpstream(`api/v1/routes/${route.id}/schedule/today?date=${date}`, input.cityId, true)),
|
||||
]);
|
||||
const days = schedules.map((schedule, index) => schedule
|
||||
? routeDepartures(route, stops, schedule, input.stopId, dates[index]) : { departures: [], partial: false });
|
||||
return { departures: days.flatMap(day => day.departures).filter(departure =>
|
||||
new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(0, 10) === input.date),
|
||||
partial: days.some(day => day.partial), isHoliday: !!schedules[1]?.isHoliday,
|
||||
holidayName: typeof schedules[1]?.holidayName === 'string' ? schedules[1].holidayName : null };
|
||||
}));
|
||||
if (routes.length && results.every(result => result.status === 'rejected')) throw new ApiError(502);
|
||||
const days = results.filter(result => result.status === 'fulfilled').map(result => result.value);
|
||||
const departures = days.flatMap(day => day.departures).map(({ trip, serviceDate, ...departure }) => departure);
|
||||
return {
|
||||
date: input.date, routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
|
||||
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
|
||||
updatedAt: new Date(now()).toISOString(), partial: days.some(day => day.partial) || days.length < routes.length,
|
||||
isHoliday: days.some(day => day.isHoliday), holidayName: days.find(day => day.holidayName)?.holidayName ?? null,
|
||||
};
|
||||
}
|
||||
async function journey(input) {
|
||||
const rawRoute = record(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId));
|
||||
const route = { id: id(rawRoute.id), number: text(rawRoute.route_number), name: text(rawRoute.name) };
|
||||
const stops = list(rawRoute.stops).map(stop => ({ ...stop, stop_id: stop.id }));
|
||||
const date = new Date(Date.parse(input.at) + 10800000).toISOString().slice(0, 10);
|
||||
const dates = [previousDate(date), date];
|
||||
const schedules = await Promise.all(dates.map(value => cachedUpstream(`api/v1/routes/${input.routeId}/schedule/today?date=${value}`, input.cityId, true)));
|
||||
const candidates = schedules.flatMap((schedule, index) => schedule ? routeDepartures(route, stops, schedule, input.stopId, dates[index]).departures : [])
|
||||
.filter(item => Math.abs(Date.parse(item.scheduledAt) - Date.parse(input.at)) < 60000 && (!input.tripKey || item.tripKey === input.tripKey));
|
||||
const unique = [...new Map(candidates.map(item => [item.tripKey, item])).values()];
|
||||
if (!unique.length) throw new ApiError(404, 'journey_not_found');
|
||||
if (unique.length > 1) throw new ApiError(409, 'journey_ambiguous');
|
||||
const departure = unique[0];
|
||||
return { route, stops: journeyStops(stops, departure), selectedStopId: input.stopId, note: departure.note,
|
||||
updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey };
|
||||
}
|
||||
async function load(input) {
|
||||
if (input.resource === 'timetable') return timetable(input);
|
||||
if (input.resource === 'journey') return journey(input);
|
||||
if (input.resource === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId);
|
||||
if (input.resource === 'nearby') return normalizeNearby(await upstream(`api/v1/stops/nearby?${new URLSearchParams({ lat: String(input.lat), lon: String(input.lon), radius: String(input.radius) })}`, input.cityId));
|
||||
const path = input.resource === 'cities' ? 'api/v1/cities'
|
||||
: input.resource === 'stops' ? 'api/v1/stops' : `api/v1/eta/stop/${input.stopId}`;
|
||||
const raw = await upstream(path, input.cityId);
|
||||
return input.resource === 'cities' ? normalizeCities(raw)
|
||||
: input.resource === 'stops' ? normalizeStops(raw) : normalizeDepartures(raw, now());
|
||||
}
|
||||
return {
|
||||
async get(input) {
|
||||
// Location searches are ephemeral: coordinates and results never enter caches.
|
||||
if (input.resource === 'nearby') return load(input);
|
||||
const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`;
|
||||
const entry = cache.get(key);
|
||||
if (entry && entry.expires > now()) return entry.data;
|
||||
if (pending.has(key)) return pending.get(key);
|
||||
const request = load(input).then(data => {
|
||||
if (cache.size >= 200) cache.delete(cache.keys().next().value);
|
||||
const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000;
|
||||
cache.set(key, { data, expires: now() + ttl });
|
||||
return data;
|
||||
}).finally(() => pending.delete(key));
|
||||
pending.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
87
server/timetable.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ApiError } from './errors.mjs';
|
||||
|
||||
export function validDate(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const time = Date.parse(`${value}T12:00:00Z`);
|
||||
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
export function previousDate(value) {
|
||||
return new Date(Date.parse(`${value}T12:00:00Z`) - 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function tripIdentity(trip, serviceDate) {
|
||||
const departureTime = String(trip.departureTime ?? '').split(':');
|
||||
const time = `${departureTime[0]?.padStart(2, '0')}:${departureTime[1]}:${departureTime[2] || '00'}`;
|
||||
return Buffer.from(JSON.stringify([serviceDate, time, trip.startStopId || null, trip.endStopId || null,
|
||||
trip.viaStopIds || [], trip.daysOfWeek || [], trip.exceptDaysOfWeek || [], trip.note || ''])).toString('base64url');
|
||||
}
|
||||
|
||||
function stopMap(rawStops) {
|
||||
if (!Array.isArray(rawStops)) throw new ApiError(502);
|
||||
return new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
|
||||
}
|
||||
|
||||
function tripArrival(trip, target, stops, serviceDate) {
|
||||
const start = trip.startStopId ? stops.get(Number(trip.startStopId)) : undefined;
|
||||
const end = trip.endStopId ? stops.get(Number(trip.endStopId)) : undefined;
|
||||
if ((trip.startStopId && !start) || (trip.endStopId && !end)) throw new ApiError(502);
|
||||
const sequence = Number(target.sequence);
|
||||
const reverse = start && end && Number(start.sequence) > Number(end.sequence);
|
||||
if (reverse ? sequence > Number(start.sequence) || sequence < Number(end.sequence)
|
||||
: (start && sequence < Number(start.sequence)) || (end && sequence > Number(end.sequence))) return null;
|
||||
const time = typeof trip.departureTime === 'string' && /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(trip.departureTime);
|
||||
const targetOffset = Number(target.time_offset_minutes ?? 0);
|
||||
const startOffset = Number(start?.time_offset_minutes ?? 0);
|
||||
const offset = start && Number(start.sequence) > sequence ? startOffset - targetOffset : targetOffset - startOffset;
|
||||
if (!time || !Number.isFinite(sequence) || !Number.isFinite(offset) || offset < 0 || offset >= 1440) throw new ApiError(502);
|
||||
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`)
|
||||
+ (Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] ?? 0) + offset * 60) * 1000).toISOString();
|
||||
const local = new Date(Date.parse(scheduledAt) + 10800000).toISOString();
|
||||
return { scheduledTime: local.slice(11, 16), scheduledAt };
|
||||
}
|
||||
|
||||
export function journeyStops(rawStops, departure) {
|
||||
const map = stopMap(rawStops);
|
||||
const result = rawStops.flatMap(stop => {
|
||||
if (!Number.isSafeInteger(Number(stop.stop_id)) || Number(stop.stop_id) <= 0 || typeof stop.name !== 'string' || !stop.name.trim()) throw new ApiError(502);
|
||||
const arrival = tripArrival(departure.trip, stop, map, departure.serviceDate);
|
||||
return arrival ? [{ id: Number(stop.stop_id), sequence: Number(stop.sequence), name: stop.name.trim(), ...arrival }] : [];
|
||||
});
|
||||
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.sequence - b.sequence);
|
||||
}
|
||||
|
||||
// RideBus times are local to Belarus (UTC+3). Preserve the actual date when
|
||||
// a trip reaches a stop after midnight instead of wrapping its clock time.
|
||||
export function routeDepartures(route, rawStops, rawSchedule, stopId, serviceDate) {
|
||||
const stops = stopMap(rawStops);
|
||||
const target = rawStops.find(stop => Number(stop.stop_id) === stopId);
|
||||
if (!target) throw new ApiError(502);
|
||||
const schedule = rawSchedule?.schedule;
|
||||
if (!schedule || typeof schedule !== 'object') throw new ApiError(502);
|
||||
let trips = schedule.trips;
|
||||
if (typeof trips === 'string') {
|
||||
try { trips = JSON.parse(trips); } catch { throw new ApiError(502); }
|
||||
}
|
||||
if (!Array.isArray(trips) || trips.length === 0) {
|
||||
if (!Array.isArray(schedule.departure_times)) throw new ApiError(502);
|
||||
trips = schedule.departure_times.map(departureTime => ({ departureTime }));
|
||||
}
|
||||
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
|
||||
const departures = [];
|
||||
let partial = false;
|
||||
for (const trip of trips) {
|
||||
if (!trip || typeof trip !== 'object') { partial = true; continue; }
|
||||
const included = Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [];
|
||||
const excluded = Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.map(Number) : [];
|
||||
if ((included.length && !included.includes(weekday)) || excluded.includes(weekday)) continue;
|
||||
try {
|
||||
const arrival = tripArrival(trip, target, stops, serviceDate);
|
||||
if (arrival) departures.push({ routeId: route.id, routeNumber: route.number, direction: route.name,
|
||||
...arrival, note: typeof trip.note === 'string' ? trip.note.trim() : '',
|
||||
tripKey: tripIdentity(trip, serviceDate), serviceDate, trip,
|
||||
});
|
||||
} catch { partial = true; }
|
||||
}
|
||||
return { departures, partial };
|
||||
}
|
||||
189
src/App.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, Bookmark, BusFront, ChevronDown, MapPin, Search, WifiOff, X } from './components/Icons';
|
||||
import { DataError, loadResource } from './lib/api';
|
||||
import { db, readPreference, savePreference, stopKey } from './lib/storage';
|
||||
import { Departures } from './components/Departures';
|
||||
import { PwaControls } from './components/PwaControls';
|
||||
import { FavoriteCard } from './components/FavoriteCard';
|
||||
import { About } from './components/About';
|
||||
import { QuickTrips } from './components/QuickTrips';
|
||||
import { NearbyStops } from './components/NearbyStops';
|
||||
import { RoutePage } from './components/RoutePage';
|
||||
import { StopList, StopListItem } from './components/StopList';
|
||||
import { positiveParam } from './lib/links';
|
||||
import { isTravelDate, localDate } from './lib/time';
|
||||
import { countLabel, groupStops, type StopGroup } from './lib/stopGroups';
|
||||
import type { Board, City, Stop } from './types';
|
||||
|
||||
function useOnline() {
|
||||
const [online, setOnline] = useState(navigator.onLine);
|
||||
useEffect(() => {
|
||||
const update = () => setOnline(navigator.onLine);
|
||||
window.addEventListener('online', update); window.addEventListener('offline', update);
|
||||
return () => { window.removeEventListener('online', update); window.removeEventListener('offline', update); };
|
||||
}, []);
|
||||
return online;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const online = useOnline();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const mode = params.get('demo') === '1' ? 'demo' : 'live';
|
||||
const demoPreviewAvailable = import.meta.env.DEV && mode === 'live' && online;
|
||||
const favoritesPage = location.pathname === '/favorites';
|
||||
const aboutPage = location.pathname === '/about';
|
||||
const routePage = location.pathname === '/route';
|
||||
const schedulePage = !favoritesPage && !aboutPage && !routePage;
|
||||
const routeId = positiveParam(params.get('route')) ?? null;
|
||||
const [view, setView] = useState<'next' | 'day'>(() => params.get('view') === 'day' ? 'day' : 'next');
|
||||
const [date, setDate] = useState(() => isTravelDate(params.get('date')) ? params.get('date')! : localDate());
|
||||
const [cityChoice, setCityChoice] = useState(() => params.get('city') || readPreference(`${mode}:city`) || '');
|
||||
const [stopChoice, setStopChoice] = useState(() => params.get('stop') || readPreference(`${mode}:stop`) || '');
|
||||
const [search, setSearch] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [nearbySelected, setNearbySelected] = useState<Stop | null>(null);
|
||||
const boardRef = useRef<HTMLElement>(null);
|
||||
const scrolledLocation = useRef<string | null>(null);
|
||||
const favorites = useLiveQuery(() => db.favorites.where('mode').equals(mode).toArray(), [mode], []);
|
||||
const trips = useLiveQuery(() => db.quickTrips.where('mode').equals(mode).toArray(), [mode], []);
|
||||
const savedCount = favorites.length + trips.length;
|
||||
const cities = useQuery({ queryKey: ['cities', mode], queryFn: () => loadResource<City[]>('cities', mode), staleTime: 300000, enabled: schedulePage });
|
||||
const city = cities.data?.data.find(item => String(item.id) === cityChoice) ?? cities.data?.data.find(item => item.slug === 'polotsk') ?? cities.data?.data[0];
|
||||
const cityId = city?.id;
|
||||
const stops = useQuery({ queryKey: ['stops', mode, cityId], queryFn: () => loadResource<Stop[]>('stops', mode, cityId), enabled: !!cityId && schedulePage, staleTime: 60000 });
|
||||
const allStops = useMemo(() => [...(stops.data?.data ?? [])].sort((a, b) => a.name.localeCompare(b.name, 'ru')), [stops.data]);
|
||||
const stopGroups = useMemo(() => groupStops(allStops), [allStops]);
|
||||
const stop = allStops.find(item => String(item.id) === stopChoice)
|
||||
?? (nearbySelected && String(nearbySelected.id) === stopChoice ? nearbySelected : undefined)
|
||||
?? favorites.find(item => item.cityId === cityId && String(item.id) === stopChoice)
|
||||
?? trips.find(item => item.cityId === cityId && String(item.stop.id) === stopChoice)?.stop;
|
||||
const selectedGroup = stopGroups.find(group => group.stops.some(item => item.id === stop?.id)) ?? (stop ? groupStops([stop])[0] : undefined);
|
||||
const board = useQuery({
|
||||
queryKey: ['departures', mode, cityId, stop?.id],
|
||||
queryFn: () => loadResource<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && schedulePage && view === 'next',
|
||||
staleTime: 30000, refetchInterval: online ? 60000 : false,
|
||||
});
|
||||
const timetable = useQuery({
|
||||
queryKey: ['timetable', mode, cityId, stop?.id, date],
|
||||
queryFn: () => loadResource<Board>('timetable', mode, cityId, stop?.id, date),
|
||||
enabled: !!cityId && !!stop && schedulePage && view === 'day', staleTime: 300000,
|
||||
});
|
||||
const key = cityId && stop ? stopKey(mode, cityId, stop.id) : '';
|
||||
const isFavorite = favorites.some(item => item.key === key);
|
||||
const filtered = stopGroups.filter(group => `${group.name} ${group.stops.map(item => item.direction).join(' ')}`.toLocaleLowerCase('ru').includes(search.trim().toLocaleLowerCase('ru')));
|
||||
const urlSuffix = mode === 'demo' ? '?demo=1' : '';
|
||||
|
||||
// Browser history and links to favorites restore their exact city/stop context.
|
||||
useEffect(() => {
|
||||
const nextCity = params.get('city'); const nextStop = params.get('stop');
|
||||
if (nextCity) { setCityChoice(nextCity); setStopChoice(nextStop ?? ''); }
|
||||
else if (nextStop) setStopChoice(nextStop);
|
||||
setView(params.get('view') === 'day' ? 'day' : 'next');
|
||||
if (isTravelDate(params.get('date'))) setDate(params.get('date')!);
|
||||
}, [params]);
|
||||
useEffect(() => { if (notice) { const timer = setTimeout(() => setNotice(''), 4500); return () => clearTimeout(timer); } }, [notice]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [location.pathname]);
|
||||
useEffect(() => { setNearbySelected(null); }, [mode, cityId]);
|
||||
const boardTarget = location.state?.boardTarget as { cityId: number; stopId: number; routeId: number } | undefined;
|
||||
const boardPending = (view === 'day' ? timetable : board).isPending;
|
||||
useEffect(() => {
|
||||
if (!schedulePage || !boardTarget || scrolledLocation.current === location.key || stops.isPending || boardPending
|
||||
|| cityId !== boardTarget.cityId || stop?.id !== boardTarget.stopId || routeId !== boardTarget.routeId || view !== 'next') return;
|
||||
const target = boardRef.current;
|
||||
if (!target) return;
|
||||
scrolledLocation.current = location.key;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
}, [schedulePage, boardTarget, location.key, stops.isPending, boardPending, cityId, stop?.id, routeId, view]);
|
||||
|
||||
function selectCity(value: string) {
|
||||
setCityChoice(value); setStopChoice(''); setSearch('');
|
||||
savePreference(`${mode}:city`, value); savePreference(`${mode}:stop`, '');
|
||||
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: value, ...(view === 'day' ? { view, date } : {}) });
|
||||
}
|
||||
function selectStop(value: Stop) {
|
||||
setStopChoice(String(value.id));
|
||||
savePreference(`${mode}:city`, String(cityId)); savePreference(`${mode}:stop`, String(value.id));
|
||||
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(value.id), ...(view === 'day' ? { view, date } : {}) });
|
||||
}
|
||||
function selectView(nextView: 'next' | 'day', nextDate = date) {
|
||||
setView(nextView); setDate(nextDate);
|
||||
const nextParams = new URLSearchParams(params);
|
||||
nextParams.delete('view'); nextParams.delete('date');
|
||||
if (nextView === 'day') { nextParams.set('view', 'day'); nextParams.set('date', nextDate); }
|
||||
setParams(nextParams);
|
||||
}
|
||||
function selectRoute(route: number | null) {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (route) nextParams.set('route', String(route)); else nextParams.delete('route');
|
||||
setParams(nextParams);
|
||||
}
|
||||
function selectGroup(group: StopGroup) {
|
||||
const selected = group.stops.find(item => String(item.id) === stopChoice);
|
||||
selectStop(selected ?? group.stops.find(item => item.routesCount > 0) ?? group.stops[0]);
|
||||
}
|
||||
async function toggleFavorite() {
|
||||
if (!city || !stop) return;
|
||||
try {
|
||||
if (isFavorite) await db.favorites.delete(key);
|
||||
else await db.favorites.put({ ...stop, key, mode, cityId: city.id, cityName: city.name, addedAt: Date.now() });
|
||||
setNotice(isFavorite ? 'Остановка удалена из избранного' : 'Остановка сохранена в избранное');
|
||||
} catch { setNotice('Не удалось сохранить избранное. Проверьте доступ к хранилищу браузера.'); }
|
||||
}
|
||||
function switchMode(demo: boolean) {
|
||||
setCityChoice(readPreference(`${demo ? 'demo' : 'live'}:city`) || '');
|
||||
setStopChoice(readPreference(`${demo ? 'demo' : 'live'}:stop`) || '');
|
||||
setSearch(''); navigate(`/${demo ? '?demo=1' : ''}`);
|
||||
}
|
||||
|
||||
return <div className="app">
|
||||
<a className="skipLink" href="#main">Перейти к расписанию</a>
|
||||
<header className="header">
|
||||
<NavLink to={`/${urlSuffix}`} className="brand" aria-label="RideBus, главная"><img src="/logo.svg" width="45" height="45" alt="" /><span>ridebus</span><span className="webTag">web</span></NavLink>
|
||||
<nav className="desktopNav" aria-label="Основная навигация"><NavLink end to={`/${urlSuffix}`}><BusFront size={18} />Расписание</NavLink><NavLink to={`/favorites${urlSuffix}`}><Bookmark size={18} />Избранное{savedCount > 0 && <span className="navCount" key={savedCount}>{savedCount}</span>}</NavLink></nav>
|
||||
<div className="headerActions"><PwaControls /></div>
|
||||
</header>
|
||||
<main id="main">
|
||||
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
|
||||
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} />Вы офлайн. Ранее просмотренные остановки и отправления доступны из сохранённых данных.</div>}
|
||||
{routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
|
||||
<section className="favoritesHero"><span className="eyebrow">ВСЕГДА ПОД РУКОЙ</span><h1>Любимые <span>остановки.</span></h1><p>Сохраните те, с которых начинается ваш день.</p></section>
|
||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
||||
{favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section>
|
||||
: <div className="favoritesGrid">{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => <FavoriteCard key={item.key} stop={item} online={online} onRemove={() => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}</div>}
|
||||
</> : <>
|
||||
<section className="hero">
|
||||
<div className="heroCopy"><h1>Ваш автобус.<br /><span>Всё по расписанию.</span></h1><p>Выберите остановку и направление,<br className="desktopBreak" /> чтобы узнать время отправления.</p></div>
|
||||
<div className="heroNote" aria-hidden="true"><img src="/star.svg" className="heroStar" alt="" /><div className="noteTop"><span className="noteDot" />RideBus</div><BusFront size={36} /><strong>Расписание<br />в вашем кармане.</strong><span className="noteBottom">Выберите остановку <ArrowDown size={20} /></span></div>
|
||||
</section>
|
||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
||||
<div className="workspace">
|
||||
<section className="stopsCard" aria-labelledby="stops-title">
|
||||
<div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2><MapPin size={22} /></div><p className="sectionDescription">Выберите город и остановку</p>
|
||||
<label className="fieldLabel" htmlFor="city">Ваш город</label><div className="citySelect"><MapPin size={18} /><select id="city" value={cityId ?? ''} disabled={!cities.data?.data.length} onChange={event => selectCity(event.target.value)}>{!city && <option value="">{cities.isPending ? 'Загружаем города…' : 'Выберите город'}</option>}{cities.data?.data.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select><ChevronDown size={17} /></div>
|
||||
{cityId && <NearbyStops key={`${mode}:${cityId}`} cityId={cityId} mode={mode} online={online} selectedGroupKey={selectedGroup?.key} favoriteStopIds={favorites.filter(item => item.cityId === cityId).map(item => item.id)} onSelect={value => { setNearbySelected(value); selectStop(value); }} />}
|
||||
<label className="fieldLabel" htmlFor="stop-search">Ваша остановка</label><div className="searchField"><Search size={18} /><input id="stop-search" type="search" placeholder="Название остановки" value={search} onChange={event => setSearch(event.target.value)} autoComplete="off" /></div>
|
||||
<StopList label="Остановки" busy={stops.isFetching || cities.isFetching}>
|
||||
{cities.isPending || (cityId && stops.isPending) ? <div className="listMessage" role="status">Загружаем остановки…</div>
|
||||
: cities.isError && !city ? <div className="listMessage"><WifiOff size={25} /><h3>{cities.error instanceof DataError && cities.error.code === 'api_not_configured' ? 'Подключаем расписание' : 'Города пока недоступны'}</h3><p>{online ? demoPreviewAvailable ? 'Попробуйте обновить данные или посмотрите, как работает RideBus, на примере.' : 'Попробуйте обновить данные немного позже.' : 'Подключитесь к интернету, чтобы загрузить города.'}</p><button className="secondaryButton" onClick={() => void cities.refetch()}>Повторить</button>{demoPreviewAvailable && <button className="textButton" onClick={() => switchMode(true)}>Посмотреть пример <ArrowRight size={15} /></button>}</div>
|
||||
: stops.isError && !stops.data ? <div className="listMessage"><h3>Нет доступных остановок</h3><p>{online ? 'Не удалось загрузить список.' : 'Для этого города ещё нет сохранённых данных.'}</p><button className="secondaryButton" onClick={() => void stops.refetch()}>Повторить</button></div>
|
||||
: filtered.length === 0 ? <div className="listMessage"><Search size={25} /><h3>{search ? 'Ничего не нашлось' : 'Пока нет остановок'}</h3><p>{search ? 'Попробуйте другое название.' : 'Выберите другой город или обновите данные.'}</p></div>
|
||||
: filtered.map(group => <StopListItem key={group.key} name={group.name} selected={selectedGroup?.key === group.key} favorite={favorites.some(f => group.stops.some(item => f.key === stopKey(mode, cityId!, item.id)))} onSelect={() => selectGroup(group)} />)}
|
||||
</StopList>
|
||||
<div className="stopsFooter">{countLabel(filtered.length, ['остановка', 'остановки', 'остановок'])}{(stops.data?.saved || cities.data?.saved || !online) && <span><WifiOff size={16} />Сохранено</span>}</div>
|
||||
</section>
|
||||
<Departures stop={stop} group={selectedGroup} onDirection={selectStop} query={view === 'day' ? timetable : board} favorite={isFavorite} onFavorite={() => void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} />
|
||||
</div>
|
||||
<section className="benefitStrip"><span className="benefitIcon"><Bookmark size={21} /></span><div><strong>Ваша остановка — в один тап</strong><p>Добавьте её в избранное, чтобы в следующий раз найти быстрее.</p></div><NavLink to={`/favorites${urlSuffix}`}>В избранное <ArrowRight size={18} /></NavLink></section>
|
||||
</>}
|
||||
</main>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
<nav className="mobileNav" aria-label="Навигация на телефоне"><NavLink end to={`/${urlSuffix}`}><BusFront size={28} /><span>Расписание</span></NavLink><NavLink to={`/favorites${urlSuffix}`} aria-label={`Избранное${savedCount ? `, сохранено: ${savedCount}` : ''}`}><span className="navIcon"><Bookmark size={28} />{savedCount > 0 && <span className="mobileBadge" key={savedCount} aria-hidden="true">{savedCount > 99 ? '99+' : savedCount}</span>}</span><span>Избранное</span></NavLink></nav>
|
||||
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
|
||||
</div>;
|
||||
}
|
||||
152
src/_features.scss
Normal file
@@ -0,0 +1,152 @@
|
||||
@mixin components {
|
||||
.appScrollArea { scrollbar-width: thin; scrollbar-color: #c7d1e8 transparent; }
|
||||
.appScrollArea::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.appScrollArea::-webkit-scrollbar-track { background: transparent; }
|
||||
.appScrollArea::-webkit-scrollbar-thumb { background: #c7d1e8; border-radius: 8px; }
|
||||
.appScrollArea::-webkit-scrollbar-thumb:hover { background: #aebedc; }
|
||||
.stopDistance { flex-shrink: 0; white-space: nowrap; font-size: 14px; color: var(--blue-muted); }
|
||||
.boardCard { scroll-margin-top: 24px; }
|
||||
.boardCard:focus-visible, .tripsCarouselTrack:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
|
||||
.departureEntry { display: flex; gap: 8px; align-items: stretch; min-width: 0; }
|
||||
.departureEntry .departureRow { flex: 1; min-width: 0; }
|
||||
.departureEntry > .shareDepartureButton { flex-shrink: 0; align-self: flex-start; margin-top: 21px; background: var(--lavender); color: var(--blue); }
|
||||
.departureEntry + .departureEntry { border-top: 1px solid var(--line); }
|
||||
.departureRow:hover .departureName strong { color: var(--blue); }
|
||||
.saveTripButton { margin: 12px 0; min-height: 44px; }
|
||||
|
||||
.quickTrips { margin: 0 0 30px; }
|
||||
.tripsTitle { margin-bottom: 16px; }
|
||||
.tripsTitle h2 { font-size: 25px; letter-spacing: -.3px; }
|
||||
.tripsTitle p { margin-top: 5px; color: var(--muted); font-size: 15px; }
|
||||
.quickTripsGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.quickTripsGrid.tripsCarouselTrack { position: relative; grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: calc((100% - 64px) / 2); align-items: start; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scroll-padding-inline: 4px; overscroll-behavior-inline: contain; padding: 4px 4px 12px; margin: -4px -4px 0; }
|
||||
.tripsCarouselTrack {
|
||||
--fade-start: #000;
|
||||
--fade-end: #000;
|
||||
-webkit-mask-image: linear-gradient(to right, var(--fade-start), #000 32px, #000 calc(100% - 32px), var(--fade-end));
|
||||
mask-image: linear-gradient(to right, var(--fade-start), #000 32px, #000 calc(100% - 32px), var(--fade-end));
|
||||
}
|
||||
.tripsCarouselTrack[data-fade-start='true'] { --fade-start: transparent; }
|
||||
.tripsCarouselTrack[data-fade-end='true'] { --fade-end: transparent; }
|
||||
.tripsCarouselTrack > .quickTripCard { scroll-snap-align: start; }
|
||||
.quickTripCard { display: flex; flex-direction: column; padding: 24px; border-radius: 28px; background: var(--green); color: var(--green-ink); min-width: 0; }
|
||||
.quickTripHeading { display: flex; align-items: center; gap: 12px; }
|
||||
.quickTripHeading h3 { font-size: 23px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.quickTripHeading > button { margin-left: auto; flex-shrink: 0; }
|
||||
.tripSymbol { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: #ffffff70; flex-shrink: 0; }
|
||||
.tripPlace { margin-top: 15px; font-size: 16px; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.tripDirection { margin-top: 5px; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.tripNext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
|
||||
.tripNext .routeNumber { background: #ffffff90; color: var(--green-ink); min-width: 44px; height: 44px; }
|
||||
.tripNext > strong { font-size: 29px; font-variant-numeric: tabular-nums; }
|
||||
.tripNext > span:last-child { font-size: 14px; }
|
||||
.tripFollowing, .tripSaved { margin-top: 10px; font-size: 14px; line-height: 1.7; }
|
||||
.tripSaved { padding: 10px 12px; border-radius: 12px; background: #ffffff65; }
|
||||
.tripActions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 20px; padding-top: 12px; border-top: 1px solid #bdd4b6; }
|
||||
.tripActions > a { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 15px; font-weight: 800; }
|
||||
.tripActions .textButton { color: inherit; min-height: 44px; }
|
||||
.carouselControls { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 8px; }
|
||||
.carouselControls > span { font-size: 14px; color: var(--muted); }
|
||||
.carouselControls > div { display: flex; gap: 8px; }
|
||||
.carouselControls .iconButton { background: var(--lavender); color: var(--blue); }
|
||||
.carouselPrevious > .vkuiIcon { transform: rotate(180deg); }
|
||||
|
||||
.appDialog { border: 0; padding: 0; margin: auto; width: min(460px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border-radius: 28px; background: white; color: var(--ink); box-shadow: 0 20px 80px #14274b35; }
|
||||
.appDialog::backdrop { background: #14274b70; backdrop-filter: blur(5px); }
|
||||
.dialogBody { padding: 26px; }
|
||||
.dialogHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
||||
.dialogHeading h2 { font-size: 25px; line-height: 1.4; }
|
||||
.dialogHeading .iconButton { margin-top: -4px; margin-right: -8px; }
|
||||
.dialogDescription { color: var(--muted); font-size: 15px; line-height: 1.8; }
|
||||
.tripForm input, .tripForm select, .shareLinkForm input { width: 100%; min-width: 0; border: 1px solid #c3d3f5; border-radius: 13px; min-height: 48px; padding: 11px 12px; font-size: 16px; background: var(--lavender); color: var(--blue); }
|
||||
.namePresets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.namePresets .secondaryButton { padding: 10px 14px; min-height: 44px; }
|
||||
.tripForm > .primaryButton { margin-top: 24px; min-height: 48px; }
|
||||
.formError { margin-top: 16px; font-size: 15px; color: #a34239; }
|
||||
.shareLinkForm p { font-size: 14px; color: var(--muted); line-height: 1.8; margin: 12px 0 20px; }
|
||||
.routeHero .shareDepartureButton { margin-top: 16px; min-height: 44px; max-width: 100%; }
|
||||
|
||||
.hourlyHeading { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.hourlyHeading p { font-size: 14px; color: var(--muted); }
|
||||
.hourlyHeading .textButton { min-height: 44px; }
|
||||
.hourRow { display: grid; grid-template-columns: 45px minmax(0, 1fr); gap: 12px; padding: 14px 0; border-top: 1px solid var(--line); }
|
||||
.hourRow h3 { font-size: 22px; font-weight: 800; color: var(--blue); padding-top: 7px; font-variant-numeric: tabular-nums; }
|
||||
.hourRow h3 span { display: block; font-size: 13px; font-weight: 600; color: var(--muted); margin-top: -3px; }
|
||||
.hourDepartures { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 8px; align-content: start; }
|
||||
.hourDeparture { display: flex; justify-content: center; align-items: center; gap: 7px; min-height: 44px; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; background: var(--lavender); color: var(--blue); position: relative; }
|
||||
.hourDeparture strong { font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.hourRoute { font-size: 13px; font-weight: 800; }
|
||||
.hourDeparture.pastDeparture { background: #f4f4f1; color: #69758c; }
|
||||
.hourDeparture.nextDeparture { background: var(--green); border-color: #9db994; color: var(--green-ink); }
|
||||
.hourDeparture:hover { border-color: var(--blue-muted); }
|
||||
.hasTripNote { font-size: 14px; position: absolute; right: 4px; top: -2px; }
|
||||
|
||||
.serviceAlerts { padding: 18px; border-radius: 18px; background: #fff7e9; color: #735421; margin: 18px 0; min-width: 0; }
|
||||
.alertsHeading { display: flex; align-items: center; gap: 8px; font-size: 15px; }
|
||||
.serviceAlert { padding-top: 14px; }
|
||||
.serviceAlert + .serviceAlert { margin-top: 14px; border-top: 1px solid #eadcc6; }
|
||||
.alertRoutes { display: block; font-size: 14px; font-weight: 800; margin-bottom: 5px; }
|
||||
.serviceAlert h3 { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.serviceAlert p { margin: 7px 0 10px; font-size: 15px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.serviceAlert small, .alertSaved { display: block; font-size: 13px; line-height: 1.7; }
|
||||
.alertSaved { margin-top: 8px; }
|
||||
.alertsUnavailable { display: flex; align-items: flex-start; gap: 7px; font-size: 14px; color: var(--muted); line-height: 1.7; margin-top: 16px; }
|
||||
.alertsUnavailable .vkuiIcon { margin-top: 4px; }
|
||||
.compactAlerts { padding: 14px; }
|
||||
|
||||
.nearbySection { margin-top: 14px; }
|
||||
.nearbyTrigger { width: 100%; min-height: 44px; }
|
||||
.nearbyMessage, .nearbyResults > p { font-size: 14px; color: var(--blue-muted); line-height: 1.7; margin-top: 10px; }
|
||||
|
||||
.routePage { padding: 32px 0 48px; }
|
||||
.backToSchedule { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 750; color: var(--blue); min-height: 44px; }
|
||||
.backToSchedule > .vkuiIcon { transform: rotate(180deg); }
|
||||
.routeHero { display: flex; align-items: flex-start; gap: 22px; padding: 30px 0; }
|
||||
.routeHero > div { min-width: 0; }
|
||||
.routeHero > .routeNumber { min-width: 64px; height: 64px; font-size: 25px; border-radius: 20px; margin-top: 5px; }
|
||||
.routeHero h1 { font-size: clamp(26px, 3.2vw, 40px); line-height: 1.3; margin: 10px 0; letter-spacing: -.3px; overflow-wrap: anywhere; }
|
||||
.routeHero p { font-size: 15px; line-height: 1.7; color: var(--muted); }
|
||||
.routeBody { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.routeBody:has(> .serviceAlerts) { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.routeBody > .serviceAlerts { margin: 0; }
|
||||
.routeTimelineCard { padding: 28px; border: 1px solid var(--line); border-radius: 32px; background: white; min-width: 0; }
|
||||
.routeTimelineHeading { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.routeTimelineHeading h2 { font-size: 24px; }
|
||||
.routeTimelineHeading > span { font-size: 14px; color: var(--blue); background: var(--lavender); padding: 4px 9px; border-radius: 20px; }
|
||||
.journeyTimeline { list-style: none; margin: 20px 0; padding: 0; }
|
||||
.journeyTimeline li { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 12px; min-height: 72px; position: relative; }
|
||||
.journeyTimeline li:not(:last-child)::before { content: ''; position: absolute; top: 50%; bottom: -50%; left: 26px; width: 2px; background: #dce0ee; }
|
||||
.journeyPoint { position: relative; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: var(--blue); background: var(--lavender); border: 2px solid white; font-size: 13px; font-weight: 800; }
|
||||
.journeyTimeline a { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-height: 44px; }
|
||||
.journeyTimeline a strong { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.journeyTimeline a span { font-size: 13px; color: var(--blue-muted); }
|
||||
.journeyTimeline a:hover strong { color: var(--blue); }
|
||||
.journeyTime { text-align: right; }
|
||||
.journeyTime strong { font-size: 21px; font-variant-numeric: tabular-nums; }
|
||||
.journeyTime small { display: block; max-width: 85px; font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.journeyTimeline .selectedJourneyStop { border-radius: 15px; background: var(--blue-soft); }
|
||||
.selectedJourneyStop .journeyPoint { background: var(--blue); color: white; }
|
||||
.journeyTimeline .passedJourneyStop:not(.selectedJourneyStop) { color: var(--muted); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.quickTripsGrid { grid-template-columns: 1fr; gap: 14px; }
|
||||
.quickTripsGrid.tripsCarouselTrack { grid-auto-columns: calc(100% - 30px); }
|
||||
.quickTripCard { padding: 20px; border-radius: 26px; }
|
||||
.quickTripHeading { gap: 8px; }
|
||||
.quickTripHeading h3 { font-size: 21px; flex: 1; min-width: 0; }
|
||||
.quickTripHeading > button { margin-left: 0; }
|
||||
.tripSymbol { width: 36px; height: 36px; border-radius: 12px; }
|
||||
.tripsTitle h2 { font-size: 23px; }
|
||||
.routeBody:has(> .serviceAlerts) { grid-template-columns: 1fr; }
|
||||
.routeHero { gap: 14px; padding: 24px 0; }
|
||||
.routeHero > .routeNumber { min-width: 48px; height: 48px; font-size: 20px; border-radius: 14px; }
|
||||
.routeTimelineCard { padding: 20px 12px; border-radius: 26px; }
|
||||
.routeTimelineHeading, .routeTimelineCard .boardFooter, .routeTimelineCard > .savedNotice { margin-left: 6px; margin-right: 6px; }
|
||||
.journeyTimeline li { gap: 9px; padding: 12px 6px; grid-template-columns: 30px minmax(0, 1fr) auto; }
|
||||
.journeyTimeline li:not(:last-child)::before { left: 20px; }
|
||||
.journeyTime strong { font-size: 19px; }
|
||||
.hourRow { grid-template-columns: 34px minmax(0, 1fr); gap: 8px; }
|
||||
.hourDepartures { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 6px; }
|
||||
.dialogBody { padding: 22px; }
|
||||
}
|
||||
}
|
||||
120
src/_motion.scss
Normal file
@@ -0,0 +1,120 @@
|
||||
@mixin components {
|
||||
:root {
|
||||
--motion-fast: 160ms;
|
||||
--motion-enter: 280ms;
|
||||
--motion-ease: cubic-bezier(.2, .75, .25, 1);
|
||||
}
|
||||
|
||||
.heroCopy, .favoritesHero, .aboutIntro,
|
||||
.stopsCard, .boardCard, .favoritesEmpty,
|
||||
.favoriteCard, .quickTripCard, .routeHero, .routeTimelineCard,
|
||||
.aboutGrid > article, .benefitStrip {
|
||||
animation: surfaceEnter var(--motion-enter) var(--motion-ease) backwards;
|
||||
}
|
||||
.stopsCard { animation-delay: 35ms; }
|
||||
.boardCard, .aboutGrid > article:nth-child(2) { animation-delay: 75ms; }
|
||||
.heroNote { animation: noteEnter 360ms var(--motion-ease) 60ms backwards; }
|
||||
.favoriteCard {
|
||||
transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-fast) ease;
|
||||
@for $index from 2 through 6 {
|
||||
&:nth-child(#{$index}) { animation-delay: ($index - 1) * 30ms; }
|
||||
}
|
||||
&:nth-child(n + 13) { animation: none; }
|
||||
}
|
||||
|
||||
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
||||
.textButton, .showMore, .refreshButton, .stopItem,
|
||||
.directionChips button, .routeFilters button, .scheduleViews button,
|
||||
.desktopNav a, .mobileNav a, .hourDeparture, .nearbyResults button {
|
||||
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease,
|
||||
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
|
||||
transform var(--motion-fast) var(--motion-ease);
|
||||
}
|
||||
.primaryButton:active, .secondaryButton:active, .installButton:active,
|
||||
.iconButton:not(:disabled):active, .textButton:not(:disabled):active,
|
||||
.showMore:active, .refreshButton:not(:disabled):active,
|
||||
.stopItem:active, .directionChips button:active, .routeFilters button:active,
|
||||
.scheduleViews button:active, .desktopNav a:active, .mobileNav a:active,
|
||||
.hourDeparture:active, .nearbyResults button:active {
|
||||
transform: scale(.97);
|
||||
}
|
||||
.stopEnd, .favoriteLink > .vkuiIcon, .benefitStrip > a > .vkuiIcon,
|
||||
.primaryButton > .vkuiIcon, .secondaryButton > .vkuiIcon,
|
||||
.footer a > .vkuiIcon {
|
||||
transition: transform var(--motion-fast) var(--motion-ease), opacity var(--motion-fast) ease;
|
||||
}
|
||||
.activeStop .stopEnd { opacity: 1; }
|
||||
.citySelect, .searchField, .scheduleDate input {
|
||||
transition: box-shadow var(--motion-fast) ease, border-color var(--motion-fast) ease;
|
||||
}
|
||||
.bookmarkMotion { display: inline-flex; animation: bookmarkPop 250ms var(--motion-ease); }
|
||||
.navCount, .mobileBadge { animation: badgePop 250ms var(--motion-ease); }
|
||||
.scheduleDate, .boardEmpty, .listMessage, .favoriteDepartures ul {
|
||||
animation: surfaceEnter 220ms var(--motion-ease) backwards;
|
||||
}
|
||||
.departureList { animation: contentEnter 180ms ease-out; }
|
||||
.departureRow { animation: rowEnter 220ms var(--motion-ease) backwards; }
|
||||
.departureEntry:nth-child(n + 17) .departureRow { animation: none; }
|
||||
.hourRow, .serviceAlerts, .nearbyResults { animation: rowEnter 220ms var(--motion-ease) backwards; }
|
||||
.hourRow:nth-child(n + 7) { animation: none; }
|
||||
.demoBanner, .offlineBanner, .savedNotice, .partialNotice { animation: contentEnter 200ms ease-out; }
|
||||
.toast, .updateToast { animation: noticeEnter 240ms var(--motion-ease) backwards; }
|
||||
.modalBackdrop { animation: contentEnter 160ms ease-out; }
|
||||
.installDialog, .appDialog { animation: dialogEnter 240ms var(--motion-ease) backwards; }
|
||||
.skeletonRow span { animation: pulse 1.2s ease-in-out infinite alternate; }
|
||||
.spinning { animation: spin 1s linear infinite; }
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.primaryButton:hover, .secondaryButton:hover, .installButton:hover { transform: translateY(-2px); }
|
||||
.favoriteCard:hover { transform: translateY(-3px); box-shadow: 0 10px 24px #2c46780d; }
|
||||
.stopItem:hover .stopEnd, .favoriteLink:hover > .vkuiIcon,
|
||||
.benefitStrip > a:hover > .vkuiIcon, .footer a:hover > .vkuiIcon,
|
||||
.primaryButton:hover > .vkuiIcon, .secondaryButton:hover > .vkuiIcon { transform: translateX(3px); }
|
||||
.primaryButton:active, .secondaryButton:active, .installButton:active { transform: scale(.97); }
|
||||
}
|
||||
|
||||
@keyframes surfaceEnter {
|
||||
from { opacity: 0; transform: translateY(7px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes noteEnter {
|
||||
from { opacity: 0; transform: translateY(7px) rotate(1deg); }
|
||||
to { opacity: 1; transform: translateY(0) rotate(3deg); }
|
||||
}
|
||||
@keyframes rowEnter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes contentEnter { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes bookmarkPop {
|
||||
0% { transform: scale(.88); }
|
||||
55% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes badgePop {
|
||||
0% { opacity: 0; transform: scale(.75); }
|
||||
65% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes noticeEnter {
|
||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
@keyframes dialogEnter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes pulse { to { opacity: .4; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
||||
.textButton, .showMore, .refreshButton, .stopItem, .favoriteCard,
|
||||
.directionChips button, .routeFilters button, .scheduleViews button,
|
||||
.desktopNav a, .mobileNav a, .stopEnd, .favoriteLink > .vkuiIcon,
|
||||
.benefitStrip > a > .vkuiIcon, .primaryButton > .vkuiIcon,
|
||||
.secondaryButton > .vkuiIcon, .footer a > .vkuiIcon,
|
||||
.hourDeparture, .nearbyResults button { transform: none !important; }
|
||||
}
|
||||
}
|
||||
14
src/_tokens.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
// RideBus landing palette, card shapes and typography.
|
||||
:root {
|
||||
--page: #faf9f6;
|
||||
--ink: #242424;
|
||||
--muted: #6f737c;
|
||||
--line: #e9e9e3;
|
||||
--blue: #2c4678;
|
||||
--blue-muted: #455e91;
|
||||
--blue-soft: #d8e2ff;
|
||||
--lavender: #f3f3fa;
|
||||
--green: #ddf9d0;
|
||||
--green-ink: #254817;
|
||||
--card-radius: 32px;
|
||||
}
|
||||
12
src/components/About.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ArrowRight, BusFront } from './Icons';
|
||||
|
||||
export function About({ urlSuffix }: { urlSuffix: string }) {
|
||||
return <section className="aboutPage" aria-labelledby="about-title">
|
||||
<div className="aboutIntro"><span className="eyebrow">О ПРОЕКТЕ</span><h1 id="about-title">Привет, <span>я Захар.</span></h1><p>И я делаю RideBus, чтобы расписание было под рукой.</p></div>
|
||||
<div className="aboutGrid">
|
||||
<article className="aboutStory"><span className="emptyIcon"><BusFront size={32} /></span><h2>От своей остановки — к вашему маршруту</h2><p>Я создал этот сайт, чтобы жителям было проще планировать поездки на общественном транспорте. Найти остановку, посмотреть время автобуса и спокойно выйти из дома — без поисков расписания по разным сайтам.</p><p>RideBus можно добавить на главный экран телефона, а любимые остановки сохранить в избранном. Надеюсь, он сделает ваши ежедневные поездки немного удобнее.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>К расписанию <ArrowRight size={18} /></NavLink></article>
|
||||
<article className="aboutSupport"><img src="/star.svg" width="64" height="64" alt="" /><h2>Нравится RideBus?</h2><p>Проект живёт благодаря моему времени и интересу. Если он помогает вам в дороге, буду рад добровольной поддержке: она пойдёт на хостинг, домен и дальнейшее развитие.</p><p>А иногда — на напиток с пеной, чтобы придумывать новые функции было веселее 🙂</p><div className="supportNote">Ссылку для поддержки добавлю чуть позже. А пока — спасибо, что пользуетесь RideBus!</div></article>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
61
src/components/Departures.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState, type Ref } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ArrowUpRight, Bookmark, Check, Clock3, Plus, RefreshCw, WifiOff } from './Icons';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { Board, City, Snapshot, Stop } from '../types';
|
||||
import { countLabel, directionLabel, type StopGroup } from '../lib/stopGroups';
|
||||
import { formatDate, formatTime, formatWait, localDate, useNow } from '../lib/time';
|
||||
import { journeyLink } from '../lib/links';
|
||||
import { QuickTripForm } from './QuickTrips';
|
||||
import { HourlyTimetable } from './HourlyTimetable';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
import { ShareDeparture } from './ShareDeparture';
|
||||
|
||||
export function Departures({ stop, group, onDirection, query, favorite, onFavorite, online, view, date, onView, routeId, onRoute, city, mode, onTripSaved, onNotice, boardRef }: {
|
||||
stop?: Stop; group?: StopGroup; onDirection: (stop: Stop) => void;
|
||||
query: UseQueryResult<Snapshot<Board>, Error>; favorite: boolean; onFavorite: () => void; online: boolean;
|
||||
view: 'next' | 'day'; date: string; onView: (view: 'next' | 'day', date?: string) => void;
|
||||
routeId: number | null; onRoute: (route: number | null) => void; city?: City; mode: 'live' | 'demo'; onTripSaved: () => void;
|
||||
onNotice: (notice: string) => void; boardRef: Ref<HTMLElement>;
|
||||
}) {
|
||||
const now = useNow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [saveTrip, setSaveTrip] = useState(false);
|
||||
useEffect(() => { setExpanded(false); }, [stop?.id, view, date, routeId]);
|
||||
const fullDay = view === 'day';
|
||||
const board = query.data?.data;
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
const departures = (board?.departures ?? []).filter(departure => (fullDay || Date.parse(departure.scheduledAt) >= now - 60000) && (!routeId || departure.routeId === routeId));
|
||||
const visible = expanded ? departures : departures.slice(0, 8);
|
||||
return <section ref={boardRef} id="departures" tabIndex={-1} className="boardCard" aria-labelledby="board-title">
|
||||
<div className="boardHeading"><span className="eyebrow"><Clock3 size={20} />{fullDay ? 'Расписание на дату' : 'Ближайшие отправления'}</span>{stop && <button className={`iconButton favoriteToggle ${favorite ? 'isFavorite' : ''}`} aria-label={favorite ? 'Убрать остановку из избранного' : 'Добавить остановку в избранное'} aria-pressed={favorite} onClick={onFavorite}><span className="bookmarkMotion" key={favorite ? 'saved' : 'unsaved'}><Bookmark size={24} fill={favorite ? 'currentColor' : 'none'} /></span></button>}</div>
|
||||
<h2 id="board-title">{group?.name ?? stop?.name ?? 'Ваша остановка'}</h2>
|
||||
{group && group.stops.length > 1 ? <div className="directionFilters" role="group" aria-label="Направления"><span className="directionLabel">Направление</span><div className="directionChips">{group.stops.map((item, index) => <button key={item.id} className={item.id === stop?.id ? 'selected' : ''} aria-pressed={item.id === stop?.id} onClick={() => onDirection(item)}>{directionLabel(group, item, index)}</button>)}</div></div>
|
||||
: <p className="boardDirection">{stop?.direction || (stop ? '' : 'Выберите остановку — покажем расписание')}</p>}
|
||||
{stop && city && !!board?.routes.length && <button className="textButton saveTripButton" onClick={() => setSaveTrip(true)}><Plus size={18} />Сохранить поездку</button>}
|
||||
{stop && <>
|
||||
<div className="scheduleControls"><div className="scheduleViews" role="group" aria-label="Режим расписания"><button className={!fullDay ? 'selected' : ''} aria-pressed={!fullDay} onClick={() => onView('next')}>Ближайшие</button><button className={fullDay ? 'selected' : ''} aria-pressed={fullDay} onClick={() => onView('day')}>На дату</button></div>{fullDay && <div className="scheduleDate"><label htmlFor="schedule-date">Дата поездки</label><input id="schedule-date" type="date" value={date} min="2000-01-02" max="2100-12-31" onChange={event => { if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} /><button className="textButton" onClick={() => onView('day', localDate(now))}>Сегодня</button></div>}</div>
|
||||
<div className="boardMeta"><span className={`scheduleBadge ${stale ? 'savedBadge' : ''}`}>{stale ? <WifiOff size={14} /> : <Check size={14} />}{stale ? 'Сохранённые данные' : 'По расписанию'}</span><span>{board ? `${formatDate(board.updatedAt)} · обновлено в ${formatTime(board.updatedAt)}` : 'Сегодня'}</span></div>
|
||||
{fullDay && <p className="timetableSummary">Все отправления за {formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}</p>}
|
||||
{board && board.routes.length > 0 && <div className="routeFilters" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</div>}
|
||||
{board && city && <Alerts mode={mode} cityId={city.id} online={online} routes={board.routes.filter(route => !routeId || route.id === routeId)} />}
|
||||
</>}
|
||||
{stale && board && <p className="savedNotice" role="status">{online ? 'Не удалось обновить расписание.' : 'Вы офлайн.'} Показываем данные, сохранённые {formatDate(board.updatedAt)} в {formatTime(board.updatedAt)}. Они могут быть устаревшими.</p>}
|
||||
{!stop ? <div className="boardEmpty"><span className="emptyIcon"><ArrowUpRight size={36} /></span><h3>Начнём с остановки</h3><p>Найдите нужную остановку<br />и выберите направление.</p></div>
|
||||
: query.isPending ? <div className="skeletonRows" aria-label="Загрузка расписания" role="status">{[1, 2, 3].map(i => <div className="skeletonRow" key={i}><i /><span /><i /></div>)}</div>
|
||||
: !board ? <div className="boardEmpty"><span className="emptyIcon"><WifiOff size={30} /></span><h3>{online ? 'Расписание пока недоступно' : 'Нет сохранённого расписания'}</h3><p>{online ? 'Попробуйте обновить данные чуть позже.' : 'Для этой остановки данные ещё не загружались. Подключитесь к интернету.'}</p><button className="secondaryButton" onClick={() => void query.refetch()} disabled={query.isFetching}>Попробовать снова</button></div>
|
||||
: departures.length === 0 ? <div className="boardEmpty"><span className="emptyIcon"><Clock3 size={30} /></span><h3>{fullDay ? 'На эту дату нет отправлений' : routeId ? 'Нет ближайших отправлений' : 'Пока нет отправлений'}</h3><p>{fullDay ? 'Попробуйте другую дату, маршрут или направление.' : stale ? 'В сохранённых данных не осталось предстоящих отправлений. Обновите расписание при подключении к сети.' : 'Для этой остановки сейчас нет ближайших отправлений. Попробуйте другое направление.'}</p>{!fullDay && <button className="secondaryButton" onClick={() => onView('day')}>Посмотреть весь день</button>}</div>
|
||||
: fullDay && city ? <HourlyTimetable key={`${stop.id}:${date}:${routeId ?? 'all'}`} departures={departures} now={now} mode={mode} cityId={city.id} stopId={stop.id} date={date} routeId={routeId} />
|
||||
: <ul className="departureList" key={`${stop.id}:${view}:${date}:${routeId ?? 'all'}`}>{visible.map((departure, i) => {
|
||||
const departed = Date.parse(departure.scheduledAt) < now - 60000;
|
||||
const wait = fullDay && localDate(Date.parse(departure.scheduledAt)) !== localDate(now) ? '' : departed ? 'отправился' : formatWait((Date.parse(departure.scheduledAt) - now) / 60000);
|
||||
return <li className="departureEntry" key={`${departure.routeId}:${departure.scheduledAt}:${i}`}><NavLink className={`departureRow ${fullDay && departed ? 'pastDeparture' : ''}`} to={journeyLink(mode, city!.id, stop.id, departure, view, date, routeId)} aria-label={`Маршрут ${departure.routeNumber}, отправление ${formatTime(departure.scheduledAt)}, посмотреть рейс`} style={{ animationDelay: `${Math.min(i, 7) * 18}ms` }}>
|
||||
<span className="routeNumber">{departure.routeNumber}</span><div className="departureName"><span className="transportLabel">Маршрут</span><strong>{departure.direction}</strong>{departure.note && <span className="tripNote">{departure.note}</span>}</div><div className="departureTime"><strong>{formatTime(departure.scheduledAt)}</strong>{wait && <span className={!departed && i === 0 ? 'soon' : ''}>{wait}</span>}</div>
|
||||
</NavLink><ShareDeparture departure={departure} cityId={city!.id} stopId={stop.id} stopName={stop.name} mode={mode} onNotice={onNotice} /></li>;
|
||||
})}</ul>}
|
||||
{board?.partial && <p className="partialNotice">Часть маршрутов временно недоступна. Показаны доступные отправления.</p>}
|
||||
{!fullDay && departures.length > 8 && !expanded && <button className="showMore" onClick={() => setExpanded(true)}>Показать ещё {departures.length - 8}</button>}
|
||||
{saveTrip && city && stop && board && <QuickTripForm city={city} stop={stop} routes={board.routes} routeId={routeId} mode={mode} onClose={() => setSaveTrip(false)} onSaved={onTripSaved} />}
|
||||
{stop && <div className="boardFooter"><span>Время по расписанию, без учёта задержек</span><button className="refreshButton" disabled={query.isFetching || !online} onClick={() => void query.refetch()}><RefreshCw size={15} className={query.isFetching ? 'spinning' : ''} />{query.isFetching ? 'Обновляем' : 'Обновить'}</button></div>}
|
||||
</section>;
|
||||
}
|
||||
19
src/components/Dialog.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLayoutEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { X } from './Icons';
|
||||
|
||||
export function Dialog({ title, children, onClose }: { title: string; children: ReactNode; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
useLayoutEffect(() => {
|
||||
const dialog = ref.current!;
|
||||
const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
dialog.querySelector<HTMLElement>('input, select, textarea')?.focus({ preventScroll: true });
|
||||
return () => { dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
}, []);
|
||||
return <dialog ref={ref} className="appDialog" aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose();
|
||||
}}><div className="dialogBody"><div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>{children}</div></dialog>;
|
||||
}
|
||||
34
src/components/FavoriteCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ArrowUpRight, MapPin, WifiOff, X } from './Icons';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { formatDate, formatTime, formatWait, useNow } from '../lib/time';
|
||||
import type { Board, SavedStop } from '../types';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
|
||||
export function FavoriteCard({ stop, online, onRemove }: { stop: SavedStop; online: boolean; onRemove: () => void }) {
|
||||
const now = useNow();
|
||||
const query = useQuery({
|
||||
queryKey: ['departures', stop.mode, stop.cityId, stop.id],
|
||||
queryFn: () => loadResource<Board>('departures', stop.mode, stop.cityId, stop.id),
|
||||
staleTime: 30000, refetchInterval: online ? 60000 : false,
|
||||
});
|
||||
const board = query.data?.data;
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
const departures = (board?.departures ?? []).filter(item => Date.parse(item.scheduledAt) >= now - 60000).slice(0, 3);
|
||||
const params = new URLSearchParams({ ...(stop.mode === 'demo' ? { demo: '1' } : {}), city: String(stop.cityId), stop: String(stop.id) });
|
||||
return <article className="favoriteCard">
|
||||
<div className="favoriteCity"><MapPin size={15} />{stop.cityName}<button className="iconButton" aria-label={`Удалить ${stop.name} из избранного`} onClick={onRemove}><X size={17} /></button></div>
|
||||
<h2>{stop.name}</h2><p>{stop.direction || 'Все направления'}</p>
|
||||
<div className="favoriteDepartures" aria-label="Ближайшие отправления">
|
||||
{query.isPending ? <p role="status">Загружаем отправления…</p>
|
||||
: !board ? <p>{online ? 'Не удалось загрузить отправления' : 'Нет сохранённых отправлений'}</p>
|
||||
: departures.length === 0 ? <p>{stale ? 'В сохранённых данных нет ближайших рейсов' : 'Ближайших отправлений пока нет'}</p>
|
||||
: <ul>{departures.map(item => <li key={`${item.routeId}:${item.scheduledAt}`} title={item.direction}><span className="favoriteRoute">{item.routeNumber}</span><strong>{formatTime(item.scheduledAt)}</strong><span>{formatWait((Date.parse(item.scheduledAt) - now) / 60000)}</span></li>)}</ul>}
|
||||
</div>
|
||||
{stale && board ? <div className="favoriteStatus"><WifiOff size={15} /><span>Сохранено {formatDate(board.updatedAt)} в {formatTime(board.updatedAt)} · данные могут быть устаревшими</span></div>
|
||||
: board?.partial && <div className="favoriteStatus">Доступна часть маршрутов</div>}
|
||||
{board && <Alerts mode={stop.mode} cityId={stop.cityId} online={online} routes={board.routes} compact />}
|
||||
<NavLink className="favoriteLink" to={`/?${params}`}>Посмотреть расписание <ArrowUpRight size={20} /></NavLink>
|
||||
</article>;
|
||||
}
|
||||
23
src/components/HourlyTimetable.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useRef } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { groupByHour } from '../lib/timetable';
|
||||
import { journeyLink } from '../lib/links';
|
||||
import { formatTime } from '../lib/time';
|
||||
import { ArrowDown } from './Icons';
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function HourlyTimetable({ departures, now, mode, cityId, stopId, date, routeId }: { departures: Departure[]; now: number; mode: 'live' | 'demo'; cityId: number; stopId: number; date: string; routeId: number | null }) {
|
||||
const nearest = departures.find(departure => Date.parse(departure.scheduledAt) >= now - 60000);
|
||||
const nextRef = useRef<HTMLAnchorElement>(null);
|
||||
const groups = groupByHour(departures);
|
||||
return <div className="hourlyTimetable"><div className="hourlyHeading"><p>Нажмите на время, чтобы посмотреть рейс.</p>{nearest && <button className="textButton" onClick={() => {
|
||||
nextRef.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
nextRef.current?.focus({ preventScroll: true });
|
||||
}}>К ближайшему <ArrowDown size={17} /></button>}</div><div className="hourGroups" aria-label="Расписание по часам">{groups.map(({ hour, departures }, groupIndex) => <section className="hourRow" key={hour} aria-label={`${hour} часов`} style={{ animationDelay: `${Math.min(groupIndex, 5) * 20}ms` }}><h3>{hour}</h3><div className="hourDepartures">{departures.map((departure, index) => {
|
||||
const next = departure === nearest;
|
||||
const past = Date.parse(departure.scheduledAt) < now - 60000;
|
||||
return <NavLink key={`${departure.routeId}:${departure.scheduledAt}:${index}`} ref={next ? nextRef : undefined} className={`hourDeparture ${past ? 'pastDeparture' : ''} ${next ? 'nextDeparture' : ''}`} to={journeyLink(mode, cityId, stopId, departure, 'day', date, routeId)} title={`${departure.direction}${departure.note ? ` · ${departure.note}` : ''}`} aria-label={`Маршрут ${departure.routeNumber}, отправление ${formatTime(departure.scheduledAt)}${next ? ', ближайший рейс' : ''}`}>
|
||||
{!routeId && <span className="hourRoute">{departure.routeNumber}</span>}<strong>{formatTime(departure.scheduledAt)}</strong>{departure.note && <span className="hasTripNote" aria-label="Есть примечание">•</span>}
|
||||
</NavLink>;
|
||||
})}</div></section>)}</div></div>;
|
||||
}
|
||||
42
src/components/Icons.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Icon20ArrowDownOutline, Icon20ArrowRightOutline, Icon20ArrowUpRightOutline,
|
||||
Icon20BusOutline, Icon20Check, Icon20ClockOutline, Icon20DownloadOutline,
|
||||
Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline,
|
||||
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
|
||||
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
|
||||
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
|
||||
} from '@vkontakte/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
type VkIcon = ComponentType<{ width?: number; height?: number; className?: string }>;
|
||||
|
||||
function adapt(Icon: VkIcon) {
|
||||
return function AppIcon({ size = 20, className }: IconProps) {
|
||||
return <Icon width={size} height={size} className={className} aria-hidden="true" />;
|
||||
};
|
||||
}
|
||||
|
||||
export const ArrowDown = adapt(Icon20ArrowDownOutline);
|
||||
export const ArrowRight = adapt(Icon20ArrowRightOutline);
|
||||
export const ArrowUpRight = adapt(Icon20ArrowUpRightOutline);
|
||||
export const BusFront = adapt(Icon20BusOutline);
|
||||
export const Check = adapt(Icon20Check);
|
||||
export const Clock3 = adapt(Icon20ClockOutline);
|
||||
export const Download = adapt(Icon20DownloadOutline);
|
||||
export const WifiOff = adapt(Icon20GlobeCrossOutline);
|
||||
export const RefreshCw = adapt(Icon20RefreshOutline);
|
||||
export const X = adapt(Icon24Cancel);
|
||||
export const ChevronDown = adapt(Icon24ChevronDown);
|
||||
export const MapPin = adapt(Icon24LocationOutline);
|
||||
export const Search = adapt(Icon24SearchOutline);
|
||||
export const Plus = adapt(Icon24Add);
|
||||
export const Home = adapt(Icon24HomeOutline);
|
||||
export const Work = adapt(Icon24WorkOutline);
|
||||
export const Warning = adapt(Icon24WarningTriangleOutline);
|
||||
export const Share = adapt(Icon20ShareOutline);
|
||||
|
||||
export function Bookmark({ size = 24, className, fill }: IconProps & { fill?: string }) {
|
||||
const Icon = fill === 'currentColor' ? Icon24Bookmark : Icon24BookmarkOutline;
|
||||
return <Icon width={size} height={size} className={className} aria-hidden="true" />;
|
||||
}
|
||||
40
src/components/NearbyStops.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { loadNearby } from '../lib/api';
|
||||
import { groupStops } from '../lib/stopGroups';
|
||||
import { MapPin, RefreshCw } from './Icons';
|
||||
import { StopList, StopListItem } from './StopList';
|
||||
import type { NearbyStop, Stop } from '../types';
|
||||
|
||||
export function NearbyStops({ cityId, mode, online, onSelect, selectedGroupKey, favoriteStopIds }: { cityId: number; mode: 'live' | 'demo'; online: boolean; onSelect: (stop: Stop) => void; selectedGroupKey?: string; favoriteStopIds: number[] }) {
|
||||
const [results, setResults] = useState<NearbyStop[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [approximate, setApproximate] = useState(false);
|
||||
const request = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => { request.current++; controller.current?.abort(); }, []);
|
||||
async function searchNearby() {
|
||||
if (!online || busy) return;
|
||||
if (!navigator.geolocation || !window.isSecureContext) { setMessage('Поиск по местоположению недоступен в этом браузере. Найдите остановку по названию.'); return; }
|
||||
const current = ++request.current;
|
||||
controller.current?.abort(); controller.current = new AbortController();
|
||||
const signal = controller.current.signal;
|
||||
setBusy(true); setMessage('');
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }));
|
||||
if (current !== request.current) return;
|
||||
const stops = await loadNearby(mode, cityId, position.coords.latitude, position.coords.longitude, signal);
|
||||
if (current !== request.current) return;
|
||||
setResults(stops); setApproximate(position.coords.accuracy > 300);
|
||||
} catch (error) {
|
||||
if (current !== request.current) return;
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
||||
setMessage(code === 1 ? 'Доступ к местоположению закрыт. Можно выбрать остановку по названию.' : code === 3 ? 'Не удалось определить местоположение вовремя. Попробуйте ещё раз или найдите остановку по названию.' : 'Не удалось найти остановки рядом. Попробуйте ещё раз или воспользуйтесь поиском.');
|
||||
} finally { if (current === request.current) setBusy(false); }
|
||||
}
|
||||
const groups = groupStops(results ?? []).map(group => ({ group, nearest: (results ?? []).filter(stop => group.stops.some(item => item.id === stop.id)).sort((a, b) => a.distance - b.distance)[0] })).sort((a, b) => a.nearest.distance - b.nearest.distance);
|
||||
return <div className="nearbySection"><button className="secondaryButton nearbyTrigger" disabled={!online || busy} onClick={() => void searchNearby()}>{busy ? <RefreshCw size={18} className="spinning" /> : <MapPin size={18} />}{busy ? 'Ищем рядом…' : results ? 'Обновить поиск рядом' : 'Остановки рядом'}</button>
|
||||
{message && <p className="nearbyMessage" role="status">{message}</p>}
|
||||
{results && <div className="nearbyResults"><p>{mode === 'demo' ? 'Примеры остановок рядом' : 'В радиусе 1 км'}{approximate && ' · местоположение приблизительное'}</p>{!online && <p>Последний результат поиска · вы офлайн</p>}<StopList label="Остановки рядом">{!groups.length ? <div className="listMessage" role="status">Рядом нет остановок выбранного города. Попробуйте выбрать другой город.</div> : groups.map(({ group, nearest }) => <StopListItem key={group.key} name={group.name} selected={selectedGroupKey === group.key} favorite={group.stops.some(stop => favoriteStopIds.includes(stop.id))} distance={nearest.distance} onSelect={() => onSelect(nearest)} />)}</StopList></div>}
|
||||
</div>;
|
||||
}
|
||||
43
src/components/PwaControls.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, RefreshCw, X } from './Icons';
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||
|
||||
type InstallEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
|
||||
|
||||
export function PwaControls() {
|
||||
const [prompt, setPrompt] = useState<InstallEvent | null>(null);
|
||||
const [help, setHelp] = useState(false);
|
||||
const [installed, setInstalled] = useState(() => window.matchMedia('(display-mode: standalone)').matches);
|
||||
const { needRefresh: [refresh, setRefresh], updateServiceWorker } = useRegisterSW();
|
||||
useEffect(() => {
|
||||
const available = (event: Event) => { event.preventDefault(); setPrompt(event as InstallEvent); };
|
||||
const complete = () => { setInstalled(true); setPrompt(null); setHelp(false); };
|
||||
window.addEventListener('beforeinstallprompt', available);
|
||||
window.addEventListener('appinstalled', complete);
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', available);
|
||||
window.removeEventListener('appinstalled', complete);
|
||||
};
|
||||
}, []);
|
||||
async function install() {
|
||||
if (!prompt) { setHelp(true); return; }
|
||||
await prompt.prompt();
|
||||
await prompt.userChoice;
|
||||
setPrompt(null);
|
||||
}
|
||||
return <>
|
||||
{!installed && <button className="installButton" onClick={() => void install()}><Download size={17} /><span>Установить</span></button>}
|
||||
{help && <div className="modalBackdrop" onClick={() => setHelp(false)}>
|
||||
<section className="installDialog" role="dialog" aria-modal="true" aria-labelledby="install-title" onClick={event => event.stopPropagation()}>
|
||||
<button autoFocus className="iconButton closeDialog" aria-label="Закрыть" onClick={() => setHelp(false)}><X size={20} /></button>
|
||||
<img src="/logo.svg" width="58" height="58" alt="" />
|
||||
<h2 id="install-title">RideBus на главном экране</h2>
|
||||
<p>На iPhone откройте меню «Поделиться» в Safari и выберите «На экран Домой».</p>
|
||||
<p>На Android откройте меню браузера и выберите «Установить приложение» или «Добавить на главный экран».</p>
|
||||
<p className="muted">Если пункта установки нет, продолжайте пользоваться RideBus в браузере.</p>
|
||||
<button className="primaryButton" onClick={() => setHelp(false)}>Понятно</button>
|
||||
</section>
|
||||
</div>}
|
||||
{refresh && <div className="updateToast" role="status"><RefreshCw size={18} /><span>Доступна новая версия RideBus</span><button onClick={() => void updateServiceWorker(true)}>Обновить</button><button aria-label="Позже" onClick={() => setRefresh(false)}><X size={17} /></button></div>}
|
||||
</>;
|
||||
}
|
||||
74
src/components/QuickTrips.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Dialog } from './Dialog';
|
||||
import { ArrowRight, Home, Work, X } from './Icons';
|
||||
import { db } from '../lib/storage';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { formatTime, formatWait, useNow } from '../lib/time';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
import { TripCarousel } from './TripCarousel';
|
||||
import type { Board, City, QuickTrip, Route, Stop } from '../types';
|
||||
|
||||
export function QuickTripForm({ city, stop, routes, routeId, mode, onClose, onSaved }: {
|
||||
city: City; stop: Stop; routes: Route[]; routeId: number | null; mode: 'live' | 'demo'; onClose: () => void; onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('Домой');
|
||||
const [selected, setSelected] = useState(routeId ?? routes[0]?.id);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const route = routes.find(route => route.id === selected);
|
||||
if (!name.trim() || !route || saving) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await db.quickTrips.put({ id: crypto.randomUUID(), name: name.trim(), mode, cityId: city.id, cityName: city.name, stop, route, createdAt: Date.now() });
|
||||
onSaved(); onClose();
|
||||
} catch { setError('Не удалось сохранить поездку. Проверьте доступ к хранилищу браузера.'); setSaving(false); }
|
||||
}
|
||||
return <Dialog title="Сохранить поездку" onClose={onClose}><form onSubmit={event => void submit(event)} className="tripForm">
|
||||
<p className="dialogDescription">{city.name} · {stop.name}<br />{stop.direction}</p>
|
||||
<label className="fieldLabel" htmlFor="trip-name">Название поездки</label><input id="trip-name" autoFocus required maxLength={40} value={name} onChange={event => setName(event.target.value)} />
|
||||
<div className="namePresets"><button type="button" className="secondaryButton" onClick={() => setName('Домой')}><Home size={18} />Домой</button><button type="button" className="secondaryButton" onClick={() => setName('На работу')}><Work size={18} />На работу</button></div>
|
||||
<label className="fieldLabel" htmlFor="trip-route">Маршрут поездки</label><select id="trip-route" value={selected} onChange={event => setSelected(Number(event.target.value))}>{routes.map(route => <option key={route.id} value={route.id}>№{route.number} · {route.name}</option>)}</select>
|
||||
{error && <p className="formError" role="alert">{error}</p>}<button className="primaryButton" disabled={saving || !name.trim()} type="submit">{saving ? 'Сохраняем…' : 'Сохранить поездку'} <ArrowRight size={18} /></button>
|
||||
</form></Dialog>;
|
||||
}
|
||||
|
||||
function TripCard({ trip, online, onNotice, onEdit }: { trip: QuickTrip; online: boolean; onNotice: (notice: string) => void; onEdit: () => void }) {
|
||||
const now = useNow();
|
||||
const query = useQuery({ queryKey: ['departures', trip.mode, trip.cityId, trip.stop.id], queryFn: () => loadResource<Board>('departures', trip.mode, trip.cityId, trip.stop.id), staleTime: 30000, refetchInterval: online ? 60000 : false });
|
||||
const departures = query.data?.data.departures.filter(item => item.routeId === trip.route.id && Date.parse(item.scheduledAt) >= now - 60000).slice(0, 3) ?? [];
|
||||
const first = departures[0];
|
||||
const saved = !online || !!query.data?.saved || query.isError;
|
||||
const suffix = new URLSearchParams({ ...(trip.mode === 'demo' ? { demo: '1' } : {}), city: String(trip.cityId), stop: String(trip.stop.id), route: String(trip.route.id) });
|
||||
return <article className="quickTripCard"><div className="quickTripHeading"><span className="tripSymbol">{trip.name === 'На работу' ? <Work size={23} /> : <Home size={23} />}</span><h3>{trip.name}</h3><button className="iconButton" aria-label={`Удалить поездку ${trip.name}`} onClick={() => void db.quickTrips.delete(trip.id).then(() => onNotice('Поездка удалена')).catch(() => onNotice('Не удалось удалить поездку'))}><X size={18} /></button></div>
|
||||
<p className="tripPlace">{trip.cityName} · {trip.stop.name}</p><p className="tripDirection">{trip.stop.direction}</p>
|
||||
<div className="tripNext"><span className="routeNumber">{trip.route.number}</span>{first ? <><strong>{formatTime(first.scheduledAt)}</strong><span>{formatWait((Date.parse(first.scheduledAt) - now) / 60000)}</span></> : <span>{query.isPending ? 'Загружаем…' : !query.data ? 'Отправления недоступны' : 'Ближайших рейсов пока нет'}</span>}</div>
|
||||
{departures.length > 1 && <p className="tripFollowing">Затем: {departures.slice(1).map(item => formatTime(item.scheduledAt)).join(' · ')}</p>}
|
||||
{saved && <p className="tripSaved">{query.data ? 'Сохранённые данные · могут быть устаревшими' : 'Подключитесь к интернету для загрузки'}</p>}
|
||||
<Alerts mode={trip.mode} cityId={trip.cityId} online={online} routes={[trip.route]} compact />
|
||||
<div className="tripActions"><NavLink to={`/?${suffix}`} state={{ boardTarget: { cityId: trip.cityId, stopId: trip.stop.id, routeId: trip.route.id } }}>Открыть поездку <ArrowRight size={18} /></NavLink><button className="textButton" onClick={onEdit}>Изменить</button></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
export function QuickTrips({ trips, online, onNotice }: { trips: QuickTrip[]; online: boolean; onNotice: (notice: string) => void }) {
|
||||
const [editing, setEditing] = useState<QuickTrip | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
if (!trips.length) return null;
|
||||
async function rename(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!editing || !name.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try { await db.quickTrips.update(editing.id, { name: name.trim() }); setEditing(null); onNotice('Поездка обновлена'); }
|
||||
catch { setError('Не удалось изменить поездку'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const ordered = [...trips].sort((a, b) => a.createdAt - b.createdAt);
|
||||
return <section className="quickTrips" aria-labelledby="trips-title"><div className="tripsTitle"><h2 id="trips-title">Мои поездки</h2><p>Нужная остановка, направление и маршрут — сразу вместе.</p></div><TripCarousel count={ordered.length} itemKeys={ordered.map(trip => trip.id).join(':')}>{ordered.map(trip => <TripCard key={trip.id} trip={trip} online={online} onNotice={onNotice} onEdit={() => { setEditing(trip); setName(trip.name); setError(''); }} />)}</TripCarousel>
|
||||
{editing && <Dialog title="Изменить поездку" onClose={() => setEditing(null)}><form className="tripForm" onSubmit={event => void rename(event)}><label className="fieldLabel" htmlFor="rename-trip">Название поездки</label><input id="rename-trip" autoFocus required maxLength={40} value={name} onChange={event => setName(event.target.value)} />{error && <p className="formError" role="alert">{error}</p>}<button className="primaryButton" type="submit" disabled={saving || !name.trim()}>Сохранить</button></form></Dialog>}
|
||||
</section>;
|
||||
}
|
||||
46
src/components/RoutePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink, useSearchParams } from 'react-router-dom';
|
||||
import { DataError, loadResource } from '../lib/api';
|
||||
import { positiveParam } from '../lib/links';
|
||||
import { formatDate, formatTime, isTravelDate, localDate, useNow } from '../lib/time';
|
||||
import { ArrowRight, Clock3, RefreshCw, WifiOff } from './Icons';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
import { ShareDeparture } from './ShareDeparture';
|
||||
import type { Journey } from '../types';
|
||||
|
||||
export function RoutePage({ online, onNotice }: { online: boolean; onNotice: (notice: string) => void }) {
|
||||
const [params] = useSearchParams();
|
||||
const now = useNow();
|
||||
const mode = params.get('demo') === '1' ? 'demo' : 'live';
|
||||
const cityId = positiveParam(params.get('city'));
|
||||
const stopId = positiveParam(params.get('stop'));
|
||||
const routeId = positiveParam(params.get('route'));
|
||||
const at = params.get('at') || '';
|
||||
const time = Date.parse(at);
|
||||
const valid = !!cityId && !!stopId && !!routeId && Number.isFinite(time) && isTravelDate(localDate(time)) && new Date(time).toISOString() === at;
|
||||
const tripKey = params.get('tripKey') || undefined;
|
||||
const query = useQuery({ queryKey: ['journey', mode, cityId, stopId, routeId, at, tripKey], queryFn: () => loadResource<Journey>('journey', mode, cityId, stopId, undefined, { routeId, at, tripKey }), enabled: valid, staleTime: 300000 });
|
||||
const journey = query.data?.data;
|
||||
const selected = journey?.stops.find(stop => stop.id === stopId);
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
function scheduleLink(nextStop = stopId, nextDate?: string) {
|
||||
const back = new URLSearchParams(mode === 'demo' ? { demo: '1' } : {});
|
||||
if (cityId) back.set('city', String(cityId));
|
||||
if (nextStop) back.set('stop', String(nextStop));
|
||||
const filter = nextStop === stopId ? positiveParam(params.get('filter')) : routeId;
|
||||
if (filter) back.set('route', String(filter));
|
||||
if (params.get('view') === 'day' && isTravelDate(nextDate || params.get('date'))) { back.set('view', 'day'); back.set('date', nextDate || params.get('date')!); }
|
||||
return `/?${back}`;
|
||||
}
|
||||
return <section className="routePage"><NavLink className="backToSchedule" to={scheduleLink()}><ArrowRight size={18} />К расписанию остановки</NavLink>
|
||||
{!valid ? <div className="boardEmpty"><h1>Не удалось открыть рейс</h1><p>Выберите отправление в расписании остановки.</p></div>
|
||||
: query.isPending ? <div className="boardEmpty" role="status"><Clock3 size={32} /><p>Загружаем маршрут…</p></div>
|
||||
: !journey ? <div className="boardEmpty"><WifiOff size={30} /><h1>{online ? 'Рейс пока недоступен' : 'Нет сохранённого маршрута'}</h1><p>{query.error instanceof DataError && query.error.code === 'journey_ambiguous' ? 'На это время есть несколько вариантов рейса. Выберите отправление в полном расписании на дату.' : online ? 'Расписание могло измениться. Вернитесь к остановке и выберите рейс заново.' : 'Откройте этот рейс при подключении к интернету, чтобы сохранить его.'}</p><button className="secondaryButton" disabled={!online || query.isFetching} onClick={() => void query.refetch()}>Попробовать снова</button></div>
|
||||
: <><div className="routeHero"><span className="routeNumber">{journey.route.number}</span><div><span className="eyebrow">ВАШ РЕЙС</span><h1>{journey.route.name}</h1><p>{selected ? `${formatDate(selected.scheduledAt)} · ${formatTime(selected.scheduledAt)} на вашей остановке` : 'Остановки маршрута'}</p>{selected && <ShareDeparture departure={{ ...selected, routeId: journey.route.id, routeNumber: journey.route.number, direction: journey.route.name, tripKey: journey.tripKey }} cityId={cityId!} stopId={selected.id} stopName={selected.name} mode={mode} onNotice={onNotice} withLabel />}</div></div>
|
||||
<div className="routeBody"><div className="routeTimelineCard"><div className="routeTimelineHeading"><h2>Остановки рейса</h2><span>{journey.stops.length}</span></div>{journey.note && <p className="savedNotice">{journey.note}</p>}{stale && <p className="savedNotice">{online ? 'Не удалось обновить маршрут.' : 'Вы офлайн.'} Сохранено {formatDate(journey.updatedAt)} в {formatTime(journey.updatedAt)} · данные могут быть устаревшими.</p>}
|
||||
<ol className="journeyTimeline">{journey.stops.map((stop, index) => <li className={`${stop.id === stopId ? 'selectedJourneyStop' : ''} ${Date.parse(stop.scheduledAt) < now - 60000 ? 'passedJourneyStop' : ''}`} key={`${stop.sequence}:${stop.id}`}><span className="journeyPoint" aria-hidden="true">{index + 1}</span><NavLink to={scheduleLink(stop.id, localDate(Date.parse(stop.scheduledAt)))}><strong>{stop.name}</strong>{stop.id === stopId && <span>Ваша остановка</span>}</NavLink><div className="journeyTime"><strong>{formatTime(stop.scheduledAt)}</strong>{selected && localDate(Date.parse(stop.scheduledAt)) !== localDate(Date.parse(selected.scheduledAt)) && <small>{formatDate(stop.scheduledAt)}</small>}</div></li>)}</ol>
|
||||
<div className="boardFooter"><span>Время по расписанию, без учёта задержек</span><button className="refreshButton" disabled={!online || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={16} className={query.isFetching ? 'spinning' : ''} />Обновить</button></div>
|
||||
</div><Alerts mode={mode} cityId={cityId!} online={online} routes={[journey.route]} /></div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
26
src/components/ServiceAlerts.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { relevantAlerts } from '../lib/alerts';
|
||||
import { formatDate, formatTime } from '../lib/time';
|
||||
import { Warning, WifiOff } from './Icons';
|
||||
import type { Route, ServiceAlert } from '../types';
|
||||
|
||||
export function Alerts({ mode, cityId, online, routes, compact = false }: { mode: 'live' | 'demo'; cityId: number; online: boolean; routes?: Route[]; compact?: boolean }) {
|
||||
const query = useQuery({ queryKey: ['alerts', mode, cityId], queryFn: () => loadResource<ServiceAlert[]>('alerts', mode, cityId), staleTime: 30000, refetchInterval: online ? 60000 : false });
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const update = () => {
|
||||
const time = Date.now(); setNow(time); clearTimeout(timer);
|
||||
const boundary = (query.data?.data ?? []).flatMap(alert => [alert.startTime, alert.endTime]).filter((value): value is string => !!value).map(Date.parse).filter(value => value > time).sort((a, b) => a - b)[0];
|
||||
if (boundary) timer = setTimeout(update, Math.min(boundary - time + 1, 2147483647));
|
||||
};
|
||||
update(); document.addEventListener('visibilitychange', update); window.addEventListener('focus', update);
|
||||
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', update); window.removeEventListener('focus', update); };
|
||||
}, [query.data]);
|
||||
const alerts = relevantAlerts(query.data?.data ?? [], cityId, now, routes);
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
if (!alerts.length) return query.isError || stale ? <p className="alertsUnavailable"><WifiOff size={15} />{stale ? 'Изменения движения сейчас не обновляются' : 'Не удалось проверить изменения движения'}</p> : null;
|
||||
return <aside className={`serviceAlerts ${compact ? 'compactAlerts' : ''}`} aria-label="Изменения движения"><div className="alertsHeading"><Warning size={19} /><strong>Изменения движения</strong></div>{stale && <p className="alertSaved">Сохранённые сообщения · возможны новые изменения</p>}{alerts.map(alert => <article key={alert.id} className={`serviceAlert alert-${alert.type}`}><span className="alertRoutes">{alert.isCityWide ? 'По всему городу' : alert.routeNumbers.length ? alert.routeNumbers.map(number => `№${number}`).join(' · ') : routes?.filter(route => alert.routeIds.includes(route.id)).map(route => `№${route.number}`).join(' · ') || 'Изменение маршрута'}</span><h3>{alert.title}</h3><p>{alert.message}</p><small>{formatDate(alert.startTime)} · с {formatTime(alert.startTime)}{alert.endTime && ` до ${formatDate(alert.endTime)}, ${formatTime(alert.endTime)}`}</small></article>)}</aside>;
|
||||
}
|
||||
40
src/components/ShareDeparture.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
import { RefreshCw, Share } from './Icons';
|
||||
import { journeyLink } from '../lib/links';
|
||||
import { formatTime, localDate } from '../lib/time';
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function ShareDeparture({ departure, cityId, stopId, stopName, mode, onNotice, withLabel = false }: {
|
||||
departure: Departure; cityId: number; stopId: number; stopName: string; mode: 'live' | 'demo'; onNotice: (notice: string) => void; withLabel?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [manual, setManual] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const date = localDate(Date.parse(departure.scheduledAt));
|
||||
const url = new URL(journeyLink(mode, cityId, stopId, departure, 'day', date, departure.routeId), window.location.origin).href;
|
||||
const day = new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(departure.scheduledAt));
|
||||
const data = { title: `RideBus · №${departure.routeNumber} · ${formatTime(departure.scheduledAt)}`,
|
||||
text: `${stopName} · ${day}, ${formatTime(departure.scheduledAt)}\nМаршрут №${departure.routeNumber}: ${departure.direction}\nВремя по расписанию.${mode === 'demo' ? '\nДемонстрационный рейс.' : ''}`, url };
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(url); onNotice('Ссылка на отправление скопирована'); setManual(false); }
|
||||
catch { setManual(true); }
|
||||
}
|
||||
async function share() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true);
|
||||
try {
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||
try { await navigator.share(data); return; }
|
||||
catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; }
|
||||
}
|
||||
await copy();
|
||||
} finally { inFlight.current = false; setBusy(false); }
|
||||
}
|
||||
return <>
|
||||
<button className={withLabel ? 'secondaryButton shareDepartureButton' : 'iconButton shareDepartureButton'} disabled={busy} title="Поделиться отправлением" aria-label={withLabel ? 'Поделиться отправлением' : `Поделиться отправлением ${formatTime(departure.scheduledAt)}, маршрут ${departure.routeNumber}`} onClick={() => void share()}>
|
||||
{busy ? <RefreshCw size={20} className="spinning" /> : <Share size={20} />}{withLabel && 'Поделиться отправлением'}
|
||||
</button>
|
||||
{manual && <Dialog title="Поделиться отправлением" onClose={() => setManual(false)}><p className="dialogDescription">{stopName} · №{departure.routeNumber}<br />{day}, {formatTime(departure.scheduledAt)}</p><div className="shareLinkForm"><label className="fieldLabel" htmlFor="departure-link">Ссылка на отправление</label><input id="departure-link" readOnly autoFocus value={url} onFocus={event => event.currentTarget.select()} /><p>Если копирование недоступно, скопируйте выделенную ссылку вручную.</p><button className="primaryButton" onClick={() => void copy()}>Скопировать ссылку</button></div></Dialog>}
|
||||
</>;
|
||||
}
|
||||
16
src/components/StopList.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowRight, Bookmark } from './Icons';
|
||||
|
||||
export function StopList({ label, busy = false, children }: { label: string; busy?: boolean; children: ReactNode }) {
|
||||
return <div className="stopsList appScrollArea" aria-label={label} aria-busy={busy}>{children}</div>;
|
||||
}
|
||||
|
||||
export function StopListItem({ name, selected, favorite, distance, onSelect }: {
|
||||
name: string; selected: boolean; favorite?: boolean; distance?: number; onSelect: () => void;
|
||||
}) {
|
||||
return <button className={`stopItem ${selected ? 'activeStop' : ''}`} onClick={onSelect} aria-pressed={selected}>
|
||||
<span className="stopCopy"><strong>{name}</strong></span>
|
||||
{distance !== undefined && <span className="stopDistance">{distance < 1000 ? `${distance} м` : `${(distance / 1000).toFixed(1)} км`}</span>}
|
||||
<span className="stopEnd">{favorite ? <Bookmark size={20} fill="currentColor" /> : <ArrowRight size={20} />}</span>
|
||||
</button>;
|
||||
}
|
||||
49
src/components/TripCarousel.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowRight } from './Icons';
|
||||
|
||||
export function TripCarousel({ count, itemKeys, children }: { count: number; itemKeys: string; children: ReactNode }) {
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const [mobile, setMobile] = useState(() => matchMedia('(max-width: 760px)').matches);
|
||||
const [position, setPosition] = useState<{ first: number; previous: boolean; next: boolean; height?: number }>({ first: 0, previous: false, next: false });
|
||||
const carousel = count > 1 && (mobile || count >= 3);
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(max-width: 760px)');
|
||||
const update = () => setMobile(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const element = track.current!;
|
||||
function update() {
|
||||
const cards = [...element.children] as HTMLElement[];
|
||||
const offset = cards[0]?.offsetLeft ?? 0;
|
||||
const first = cards.reduce((closest, card, index) => Math.abs(card.offsetLeft - offset - element.scrollLeft) < Math.abs(cards[closest].offsetLeft - offset - element.scrollLeft) ? index : closest, 0);
|
||||
const previous = element.scrollLeft > 2, next = element.scrollWidth - element.clientWidth - element.scrollLeft > 2;
|
||||
const visible = cards.slice(first, first + (mobile ? 1 : 2));
|
||||
const height = carousel && visible.length ? Math.max(...visible.map(card => card.offsetHeight)) + 24 : undefined;
|
||||
if (element.scrollTop !== 0) element.scrollTop = 0;
|
||||
setPosition(current => current.first === first && current.previous === previous && current.next === next && current.height === height ? current : { first, previous, next, height });
|
||||
}
|
||||
if (!carousel) element.scrollLeft = 0;
|
||||
update(); element.addEventListener('scroll', update, { passive: true });
|
||||
const observer = new ResizeObserver(update); observer.observe(element);
|
||||
for (const card of element.children) observer.observe(card);
|
||||
return () => { observer.disconnect(); element.removeEventListener('scroll', update); };
|
||||
}, [carousel, itemKeys, mobile]);
|
||||
function move(index: number) {
|
||||
const element = track.current!;
|
||||
const first = element.children[0] as HTMLElement;
|
||||
const card = element.children[Math.max(0, Math.min(index, count - 1))] as HTMLElement;
|
||||
element.scrollTo({ left: card.offsetLeft - first.offsetLeft, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
}
|
||||
const visibleEnd = Math.min(count, position.first + (mobile ? 1 : 2));
|
||||
return <div className="tripsCollection">
|
||||
<div ref={track} className={`quickTripsGrid ${carousel ? 'tripsCarouselTrack appScrollArea' : ''}`} data-fade-start={carousel && position.previous} data-fade-end={carousel && position.next} style={carousel && position.height ? { height: position.height } : undefined} tabIndex={carousel ? 0 : undefined} role={carousel ? 'region' : undefined} aria-roledescription={carousel ? 'карусель' : undefined} aria-label={carousel ? 'Сохранённые поездки' : undefined} onKeyDown={event => {
|
||||
if (!carousel || event.target !== event.currentTarget) return;
|
||||
if (['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault(); move(event.key === 'Home' ? 0 : event.key === 'End' ? count - 1 : position.first + (event.key === 'ArrowRight' ? 1 : -1));
|
||||
}
|
||||
}}>{children}</div>
|
||||
{carousel && <div className="carouselControls"><span aria-live="polite" aria-atomic="true">{mobile ? position.first + 1 : `${position.first + 1}–${visibleEnd}`} из {count}</span><div><button className="iconButton carouselPrevious" disabled={!position.previous} aria-label="Предыдущая поездка" onClick={() => move(position.first - 1)}><ArrowRight size={21} /></button><button className="iconButton" disabled={!position.next} aria-label="Следующая поездка" onClick={() => move(position.first + 1)}><ArrowRight size={21} /></button></div></div>}
|
||||
</div>;
|
||||
}
|
||||
12
src/lib/alerts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Route, ServiceAlert } from '../types';
|
||||
|
||||
const routeNumber = (value: string) => value.replace(/[\s№]/g, '').toLocaleUpperCase('ru');
|
||||
export function relevantAlerts(alerts: ServiceAlert[], cityId: number, now: number, routes?: Route[]) {
|
||||
const seen = new Set<number>();
|
||||
return alerts.filter(alert => {
|
||||
const start = Date.parse(alert.startTime), end = alert.endTime === null ? null : Date.parse(alert.endTime);
|
||||
if (alert.cityId !== cityId || seen.has(alert.id) || !Number.isFinite(start) || start > now || (end !== null && (!Number.isFinite(end) || end <= now))) return false;
|
||||
seen.add(alert.id);
|
||||
return alert.isCityWide || !routes || routes.some(route => alert.routeIds.includes(route.id) || alert.routeNumbers.some(number => routeNumber(number) === routeNumber(route.number)));
|
||||
});
|
||||
}
|
||||
51
src/lib/api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from './storage';
|
||||
import { demoData } from './demo';
|
||||
import type { NearbyStop, Snapshot } from '../types';
|
||||
|
||||
export type ResourceOptions = { routeId?: number; at?: string; tripKey?: string };
|
||||
|
||||
export class DataError extends Error {
|
||||
constructor(public code: string) { super(code); }
|
||||
}
|
||||
|
||||
export async function loadResource<T>(
|
||||
resource: string, mode: 'live' | 'demo', cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {},
|
||||
): Promise<Snapshot<T>> {
|
||||
const key = `${mode}:${resource}:${cityId ?? ''}:${stopId ?? ''}${date ? `:${date}` : ''}${options.routeId ? `:${options.routeId}:${options.at ?? ''}:${options.tripKey ?? ''}` : ''}`;
|
||||
let data: T;
|
||||
try {
|
||||
if (!navigator.onLine) throw new DataError('offline');
|
||||
if (mode === 'demo') data = demoData(resource, cityId, stopId, date, options) as T;
|
||||
else {
|
||||
const params = new URLSearchParams({ resource });
|
||||
if (cityId) params.set('cityId', String(cityId));
|
||||
if (stopId) params.set('stopId', String(stopId));
|
||||
if (date) params.set('date', date);
|
||||
if (options.routeId) params.set('routeId', String(options.routeId));
|
||||
if (options.at) params.set('at', options.at);
|
||||
if (options.tripKey) params.set('tripKey', options.tripKey);
|
||||
const response = await fetch(`/api/data?${params}`, { signal: AbortSignal.timeout(15000), cache: 'no-store' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new DataError(body.error || 'data_unavailable');
|
||||
data = body as T;
|
||||
}
|
||||
} catch (error) {
|
||||
const snapshot = await db.snapshots.get(key).catch(() => undefined);
|
||||
if (snapshot) return { data: snapshot.data as T, savedAt: snapshot.savedAt, saved: true };
|
||||
throw error;
|
||||
}
|
||||
const savedAt = new Date().toISOString();
|
||||
// Storage failure must not hide otherwise available API data.
|
||||
await db.snapshots.put({ key, data, savedAt }).catch(() => undefined);
|
||||
return { data, savedAt, saved: false };
|
||||
}
|
||||
|
||||
export async function loadNearby(mode: 'live' | 'demo', cityId: number, lat: number, lon: number, signal: AbortSignal): Promise<NearbyStop[]> {
|
||||
if (mode === 'demo') return (demoData('stops', cityId) as NearbyStop[]).slice(0, 4).map((stop, index) => ({ ...stop, distance: 120 + index * 90 }));
|
||||
const params = new URLSearchParams({ resource: 'nearby', cityId: String(cityId), lat: lat.toFixed(5), lon: lon.toFixed(5), radius: '1000' });
|
||||
const response = await fetch(`/api/data?${params}`, { cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new DataError(data.error || 'data_unavailable');
|
||||
// Nearby coordinates and results stay in memory, outside persistent snapshots.
|
||||
return data as NearbyStop[];
|
||||
}
|
||||
66
src/lib/demo.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Board, City, Journey, ServiceAlert, Stop, Timetable } from '../types';
|
||||
import type { ResourceOptions } from './api';
|
||||
|
||||
const cities: City[] = [
|
||||
{ id: 1, name: 'Полоцк', slug: 'polotsk' },
|
||||
{ id: 2, name: 'Новополоцк', slug: 'novopolotsk' },
|
||||
];
|
||||
const stops: Stop[] = [
|
||||
{ id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 },
|
||||
{ id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3 },
|
||||
{ id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5 },
|
||||
{ id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2 },
|
||||
{ id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3 },
|
||||
{ id: 106, name: 'Университет', direction: 'В сторону автовокзала', routesCount: 2 },
|
||||
];
|
||||
|
||||
export function demoData(resource: string, cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {}): City[] | Stop[] | Board | Timetable | ServiceAlert[] | Journey {
|
||||
if (resource === 'cities') return cities;
|
||||
if (resource === 'stops') return cityId === 2
|
||||
? stops.map(stop => ({ ...stop, id: stop.id + 100, name: stop.id === 101 ? 'Площадь Строителей' : stop.name })) : stops;
|
||||
const now = Date.now();
|
||||
const routes = [
|
||||
{ id: 1, number: '4', name: 'Автовокзал → Больница' },
|
||||
{ id: 2, number: '10', name: 'Вокзал → Аэропорт' },
|
||||
{ id: 3, number: '12', name: 'Боровуха → Автовокзал' },
|
||||
{ id: 4, number: '24', name: 'Центр → Университет' },
|
||||
];
|
||||
if (resource === 'alerts') return cityId === 2 ? [] : [{
|
||||
id: 1, cityId: 1, type: 'detour', title: 'Изменение маршрутов 4 и 12',
|
||||
message: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.',
|
||||
routeNumbers: ['4', '12'], routeIds: [1, 3], isCityWide: false,
|
||||
startTime: new Date(now - 3600000).toISOString(), endTime: new Date(now + 14400000).toISOString(),
|
||||
}];
|
||||
if (resource === 'journey') {
|
||||
const cityStops = (demoData('stops', cityId) as Stop[]);
|
||||
const selected = cityStops.findIndex(stop => stop.id === stopId);
|
||||
const at = Date.parse(options.at || new Date(now).toISOString());
|
||||
return { route: routes.find(route => route.id === options.routeId) || routes[0], selectedStopId: stopId || cityStops[0].id,
|
||||
note: '', updatedAt: new Date(now).toISOString(), stops: cityStops.map((stop, index) => {
|
||||
const scheduledAt = new Date(at + (index - Math.max(selected, 0)) * 300000).toISOString();
|
||||
return { id: stop.id, sequence: index + 1, name: stop.name, scheduledAt,
|
||||
scheduledTime: new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(scheduledAt)) };
|
||||
}) };
|
||||
}
|
||||
if (resource === 'timetable' && date) return {
|
||||
date, routes, partial: false, updatedAt: new Date(now).toISOString(), isHoliday: false, holidayName: null,
|
||||
departures: Array.from({ length: 32 }, (_, i) => {
|
||||
const route = routes[(i + (stopId ?? 0)) % routes.length];
|
||||
const minutes = 360 + i * 30;
|
||||
const scheduledTime = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||
return { routeId: route.id, routeNumber: route.number, direction: route.name, scheduledTime,
|
||||
scheduledAt: new Date(`${date}T${scheduledTime}:00+03:00`).toISOString() };
|
||||
}),
|
||||
};
|
||||
return {
|
||||
routes, partial: false, updatedAt: new Date(now).toISOString(),
|
||||
departures: [4, 9, 15, 22, 31, 38, 46, 57].map((minutes, i) => {
|
||||
const route = routes[(i + (stopId ?? 0)) % routes.length];
|
||||
const scheduledAt = new Date(now + minutes * 60000).toISOString();
|
||||
return {
|
||||
routeId: route.id, routeNumber: route.number, direction: route.name, scheduledAt,
|
||||
scheduledTime: new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(scheduledAt)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
10
src/lib/links.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function positiveParam(value: string | null) {
|
||||
const number = Number(value);
|
||||
return value && /^[1-9]\d*$/.test(value) && Number.isSafeInteger(number) ? number : undefined;
|
||||
}
|
||||
export function journeyLink(mode: 'live' | 'demo', cityId: number, stopId: number, departure: Departure, view: 'next' | 'day', date: string, filter: number | null) {
|
||||
return `/route?${new URLSearchParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(stopId), route: String(departure.routeId), at: departure.scheduledAt,
|
||||
...(departure.tripKey ? { tripKey: departure.tripKey } : {}), ...(view === 'day' ? { view, date } : {}), ...(filter ? { filter: String(filter) } : {}) })}`;
|
||||
}
|
||||
32
src/lib/stopGroups.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Stop } from '../types';
|
||||
|
||||
export type StopGroup = { key: string; name: string; stops: Stop[] };
|
||||
|
||||
// Match the landing: preserve directional stop IDs while listing each name once.
|
||||
export function groupStops(stops: Stop[]): StopGroup[] {
|
||||
const groups = new Map<string, StopGroup>();
|
||||
const seen = new Set<number>();
|
||||
for (const stop of stops) {
|
||||
if (seen.has(stop.id)) continue;
|
||||
seen.add(stop.id);
|
||||
const name = stop.name.trim().replace(/\s+/g, ' ');
|
||||
const key = name.normalize('NFKC').toLocaleLowerCase('ru');
|
||||
const group = groups.get(key) ?? { key, name, stops: [] };
|
||||
group.stops.push(stop);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, 'ru', { numeric: true }));
|
||||
}
|
||||
|
||||
export function directionLabel(group: StopGroup, stop: Stop, index: number): string {
|
||||
if (!stop.direction) return group.stops.length === 1 ? 'Отправления с этой остановки' : `Направление ${index + 1}`;
|
||||
const repeated = group.stops.filter(item => item.direction === stop.direction).length > 1;
|
||||
return `${stop.direction}${repeated ? ` · ${index + 1}` : ''}`;
|
||||
}
|
||||
|
||||
export function countLabel(count: number, forms: [string, string, string]): string {
|
||||
const lastTwo = count % 100;
|
||||
const last = count % 10;
|
||||
const form = lastTwo >= 11 && lastTwo <= 14 ? forms[2] : last === 1 ? forms[0] : last >= 2 && last <= 4 ? forms[1] : forms[2];
|
||||
return `${count} ${form}`;
|
||||
}
|
||||
19
src/lib/storage.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import type { QuickTrip, SavedStop } from '../types';
|
||||
|
||||
export const db = new Dexie('ridebus-pwa') as Dexie & {
|
||||
snapshots: EntityTable<{ key: string; data: unknown; savedAt: string }, 'key'>;
|
||||
favorites: EntityTable<SavedStop, 'key'>;
|
||||
quickTrips: EntityTable<QuickTrip, 'id'>;
|
||||
};
|
||||
db.version(1).stores({ snapshots: 'key', favorites: 'key, cityId, mode' });
|
||||
db.version(2).stores({ snapshots: 'key', favorites: 'key, cityId, mode', quickTrips: 'id, mode, cityId' });
|
||||
|
||||
export const stopKey = (mode: string, cityId: number, stopId: number) => `${mode}:${cityId}:${stopId}`;
|
||||
|
||||
export function readPreference(key: string) {
|
||||
try { return localStorage.getItem(`ridebus:${key}`); } catch { return null; }
|
||||
}
|
||||
export function savePreference(key: string, value: string) {
|
||||
try { localStorage.setItem(`ridebus:${key}`, value); } catch { /* browsing still works without storage */ }
|
||||
}
|
||||
33
src/lib/time.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||
}
|
||||
export function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long' }).format(new Date(value));
|
||||
}
|
||||
export function localDate(time = Date.now()) {
|
||||
return new Date(time + 10800000).toISOString().slice(0, 10);
|
||||
}
|
||||
export function isTravelDate(value: string | null): value is string {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value) || value < '2000-01-02' || value > '2100-12-31') return false;
|
||||
const time = Date.parse(`${value}T12:00:00Z`);
|
||||
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
export function formatWait(minutes: number) {
|
||||
const total = Math.max(0, Math.ceil(minutes));
|
||||
if (total === 0) return 'сейчас';
|
||||
const hours = Math.floor(total / 60);
|
||||
const remainder = total % 60;
|
||||
return `через ${hours ? `${hours} ч${remainder ? ' ' : ''}` : ''}${remainder ? `${remainder} мин` : ''}`;
|
||||
}
|
||||
export function useNow() {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const update = () => setNow(Date.now());
|
||||
const timer = window.setInterval(update, 15000);
|
||||
document.addEventListener('visibilitychange', update);
|
||||
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', update); };
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
10
src/lib/timetable.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function groupByHour(departures: Departure[]) {
|
||||
const hours = new Map<string, Departure[]>();
|
||||
for (const departure of [...departures].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt))) {
|
||||
const hour = new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(11, 13);
|
||||
hours.set(hour, [...(hours.get(hour) ?? []), departure]);
|
||||
}
|
||||
return [...hours].map(([hour, departures]) => ({ hour, departures }));
|
||||
}
|
||||
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '@fontsource-variable/manrope';
|
||||
import App from './App';
|
||||
import './styles.scss';
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: 1, networkMode: 'always', refetchOnWindowFocus: true } } });
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={client}><BrowserRouter><App /></BrowserRouter></QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
260
src/styles.scss
Normal file
@@ -0,0 +1,260 @@
|
||||
@use './tokens';
|
||||
@use './motion';
|
||||
@use './features';
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Manrope Variable', 'Manrope', sans-serif; color: var(--ink); background: var(--page); font-size: 16px; line-height: 1.5; font-weight: 500; -webkit-font-smoothing: antialiased; }
|
||||
button, input, select { font: inherit; }
|
||||
button, a, select { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: default; opacity: .5; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, a { touch-action: manipulation; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
|
||||
button { color: inherit; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
svg, .vkuiIcon { flex-shrink: 0; }
|
||||
.app { max-width: 1240px; margin: auto; padding: 0 48px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; height: 112px; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; }
|
||||
.brandDot { color: var(--blue); }
|
||||
.webTag { font-size: 14px; letter-spacing: .5px; background: var(--lavender); color: var(--blue); padding: 5px 8px; border-radius: 7px; font-weight: 750; }
|
||||
.desktopNav { display: flex; gap: 6px; background: #eeeee9; border-radius: 40px; padding: 5px; }
|
||||
.desktopNav a { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 750; padding: 11px 17px; border-radius: 30px; color: var(--muted); }
|
||||
.desktopNav a.active { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
|
||||
.navCount { background: var(--lavender); border-radius: 20px; padding: 1px 7px; font-size: 13px; font-weight: 750; }
|
||||
.headerActions { display: flex; gap: 19px; align-items: center; }
|
||||
.noteDot { width: 6px; height: 6px; border-radius: 50%; background: #6e955f; }
|
||||
.installButton { display: flex; align-items: center; gap: 8px; border: 0; border-radius: 30px; padding: 11px 17px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 750; }
|
||||
.hero { display: flex; gap: 45px; align-items: center; justify-content: space-between; padding: 56px 0 42px; }
|
||||
.eyebrow { display: flex; align-items: center; gap: 8px; color: var(--blue); font-size: 14px; letter-spacing: .2px; font-weight: 800; }
|
||||
.tinyStar { font-size: 19px; line-height: 1; }
|
||||
.hero h1, .favoritesHero h1 { font-size: clamp(36px, 4.6vw, 58px); letter-spacing: -.5px; line-height: 1.13; font-weight: 780; margin: 18px 0; }
|
||||
.hero h1 span, .favoritesHero h1 span { color: var(--blue); }
|
||||
.heroCopy > p, .favoritesHero > p { font-size: 16px; line-height: 1.8; color: var(--muted); font-weight: 500; }
|
||||
.heroNote { position: relative; isolation: isolate; flex-shrink: 0; width: 280px; min-height: 252px; padding: 25px 26px; border-radius: 30px; background: var(--green); color: var(--green-ink); transform: rotate(3deg); overflow: hidden; }
|
||||
.noteTop { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 750; margin-bottom: 24px; }
|
||||
.heroNote > .vkuiIcon { position: absolute; top: 44px; right: 26px; transform: rotate(-12deg); }
|
||||
.heroNote strong { font-size: 25px; line-height: 1.2; letter-spacing: -.5px; }
|
||||
.noteBottom { display: flex; align-items: center; justify-content: space-between; margin-top: 23px; font-size: 14px; font-weight: 650; }
|
||||
.heroStar { position: absolute; z-index: -1; opacity: .16; width: 145px; height: 145px; right: -36px; bottom: -34px; filter: sepia(1) saturate(.4); }
|
||||
.workspace { display: grid; grid-template-columns: 340px minmax(0, 1fr); align-items: start; gap: 24px; }
|
||||
.stopsCard { background: var(--lavender); border-radius: var(--card-radius); padding: 26px 22px 0; overflow: hidden; color: var(--blue); }
|
||||
.sectionHeading { display: flex; align-items: center; justify-content: space-between; }
|
||||
.sectionHeading h2 { font-size: 25px; font-weight: 800; letter-spacing: -.5px; }
|
||||
.sectionDescription { margin: 6px 0 25px; font-size: 14px; color: var(--blue-muted); }
|
||||
.fieldLabel { display: block; font-size: 14px; font-weight: 750; margin: 16px 0 8px; }
|
||||
.citySelect, .searchField { display: flex; align-items: center; gap: 9px; border-radius: 14px; padding: 0 14px; height: 49px; background: white; }
|
||||
.citySelect select { appearance: none; width: 100%; border: 0; background: transparent; font-size: 16px; color: var(--blue); font-weight: 700; cursor: pointer; padding: 8px 0; }
|
||||
.searchField input { min-width: 0; width: 100%; border: 0; background: transparent; font-size: 16px; height: 100%; color: var(--blue); }
|
||||
.searchField input::placeholder { color: #69758e; font-size: 16px; }
|
||||
.citySelect:focus-within, .searchField:focus-within { box-shadow: 0 0 0 2px #aec6ff; }
|
||||
.citySelect select:focus-visible, .searchField input:focus-visible { outline: 0; }
|
||||
.stopsList { margin: 18px -8px 8px; padding: 0 8px; max-height: 398px; overflow-y: auto; }
|
||||
.stopItem { width: 100%; min-height: 54px; text-align: left; display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid transparent; border-radius: 15px; background: transparent; margin-bottom: 4px; transition: background .18s; }
|
||||
.stopItem:hover { background: #ffffff90; }
|
||||
.stopItem.activeStop { background: white; border-color: #c3d3f5; box-shadow: 0 3px 8px #2c467809; }
|
||||
.stopCopy { flex: 1; min-width: 0; }
|
||||
.stopCopy strong { font-size: 16px; line-height: 1.4; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.stopEnd { margin-left: auto; opacity: .6; }
|
||||
.stopsFooter { border-top: 1px solid #dce0ee; display: flex; justify-content: space-between; font-size: 13px; color: var(--blue-muted); padding: 14px 0 18px; }
|
||||
.stopsFooter span { display: flex; align-items: center; gap: 4px; }
|
||||
.boardCard { background: white; border: 1px solid #eeeee9; border-radius: var(--card-radius); min-height: 590px; padding: 29px 30px 20px; min-width: 0; }
|
||||
.boardHeading { display: flex; align-items: center; justify-content: space-between; min-height: 44px; gap: 10px; }
|
||||
.boardHeading .eyebrow { font-size: 15px; letter-spacing: 0; font-weight: 650; }
|
||||
.iconButton { background: transparent; border: 0; min-width: 44px; min-height: 44px; padding: 10px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.iconButton:hover { background: var(--lavender); }
|
||||
.favoriteToggle { color: #8390a9; background: var(--lavender); }
|
||||
.favoriteToggle.isFavorite { color: var(--blue); background: var(--blue-soft); }
|
||||
.boardCard h2 { font-size: 28px; line-height: 1.25; letter-spacing: -.5px; margin: 11px 0 7px; overflow-wrap: anywhere; }
|
||||
.boardDirection { font-size: 15px; color: var(--muted); line-height: 1.6; }
|
||||
.directionFilters { margin: 20px 0 8px; }
|
||||
.directionLabel { display: block; color: var(--blue-muted); font-size: 14px; font-weight: 650; margin-bottom: 10px; }
|
||||
.directionChips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.directionChips button { min-height: 44px; padding: 10px 16px; border: 0; border-radius: 24px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 650; line-height: 1.5; text-align: left; overflow-wrap: anywhere; }
|
||||
.directionChips button.selected { background: var(--blue); color: white; }
|
||||
.directionChips button:hover { background: #aec6ff; }
|
||||
.directionChips button.selected:hover { background: var(--blue-muted); }
|
||||
.navIcon { position: relative; display: inline-flex; width: 28px; height: 28px; }
|
||||
.mobileBadge { position: absolute; top: -8px; right: -13px; display: grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; border: 2px solid white; border-radius: 20px; color: white; background: var(--blue); font-size: 12px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.boardMeta { margin: 19px 0 21px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); }
|
||||
.scheduleBadge { display: flex; align-items: center; gap: 5px; padding: 6px 10px; border-radius: 30px; background: var(--green); color: var(--green-ink); font-size: 13px; font-weight: 750; }
|
||||
.savedBadge { background: #fff1d8; color: #876022; }
|
||||
.routeFilters { display: flex; flex-wrap: wrap; gap: 7px; padding-bottom: 17px; border-bottom: 1px solid var(--line); }
|
||||
.routeFilters button { border: 0; border-radius: 20px; min-height: 40px; min-width: 40px; background: var(--lavender); color: var(--blue); padding: 9px 13px; font-size: 15px; font-weight: 750; }
|
||||
.routeFilters button.selected { background: var(--blue); color: white; }
|
||||
.scheduleControls { margin-top: 22px; }
|
||||
.scheduleViews { display: inline-flex; gap: 4px; padding: 4px; background: var(--lavender); border-radius: 16px; }
|
||||
.scheduleViews button { border: 0; background: transparent; padding: 10px 16px; min-height: 44px; border-radius: 12px; font-size: 15px; font-weight: 750; color: var(--blue-muted); }
|
||||
.scheduleViews button.selected { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
|
||||
.scheduleDate { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
|
||||
.scheduleDate label { width: 100%; font-size: 14px; font-weight: 700; color: var(--muted); }
|
||||
.scheduleDate input { border: 1px solid #c3d3f5; border-radius: 12px; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 44px; color: var(--blue); background: var(--lavender); font-size: 16px; }
|
||||
.timetableSummary { margin: -5px 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
|
||||
.departureList { list-style: none; margin: 0; padding: 0; }
|
||||
.departureRow { display: flex; gap: 15px; align-items: center; min-height: 94px; padding: 17px 0; }
|
||||
.departureRow + .departureRow { border-top: 1px solid var(--line); }
|
||||
.routeNumber { display: grid; place-items: center; background: var(--blue-soft); color: var(--blue); min-width: 50px; height: 50px; padding: 6px; border-radius: 14px; font-size: 18px; letter-spacing: -.5px; font-weight: 800; }
|
||||
.departureName { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.transportLabel { font-size: 14px; color: var(--muted); }
|
||||
.departureName strong { font-size: 16px; font-weight: 700; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.tripNote { font-size: 14px; color: var(--muted); line-height: 1.6; }
|
||||
.pastDeparture .departureTime { color: var(--muted); }
|
||||
.departureTime { display: flex; align-items: flex-end; flex-direction: column; gap: 4px; margin-left: auto; flex-shrink: 0; padding-left: 5px; }
|
||||
.departureTime strong { font-size: 26px; letter-spacing: -.5px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.departureTime span { font-size: 14px; color: var(--muted); white-space: nowrap; }
|
||||
.departureTime .soon { color: var(--green-ink); background: var(--green); padding: 3px 7px; border-radius: 20px; }
|
||||
.boardFooter { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); margin-top: 16px; padding-top: 17px; }
|
||||
.boardFooter > span { font-size: 13px; color: var(--muted); }
|
||||
.refreshButton { display: flex; align-items: center; gap: 5px; background: none; border: 0; font-size: 14px; font-weight: 750; color: var(--blue); padding: 8px 0 8px 8px; }
|
||||
.boardEmpty { min-height: 340px; padding: 50px 12px; display: flex; align-items: center; flex-direction: column; justify-content: center; text-align: center; }
|
||||
.emptyIcon { display: grid; place-items: center; width: 76px; height: 76px; background: var(--lavender); color: var(--blue); border-radius: 24px; transform: rotate(-6deg); margin-bottom: 22px; }
|
||||
.boardEmpty h3 { font-size: 18px; letter-spacing: -.5px; }
|
||||
.boardEmpty p { font-size: 15px; color: var(--muted); line-height: 1.7; max-width: 340px; margin: 10px auto 20px; }
|
||||
.listMessage { display: flex; align-items: center; flex-direction: column; gap: 12px; text-align: center; padding: 28px 8px; font-size: 15px; line-height: 1.6; }
|
||||
.listMessage h3 { font-size: 16px; }
|
||||
.listMessage p { color: var(--blue-muted); }
|
||||
.primaryButton, .secondaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 30px; padding: 13px 20px; font-size: 15px; font-weight: 750; background: var(--blue); color: white; }
|
||||
.secondaryButton { background: var(--blue-soft); color: var(--blue); }
|
||||
.textButton { border: 0; background: transparent; color: var(--blue); display: flex; align-items: center; gap: 5px; padding: 8px; font-size: 14px; font-weight: 750; }
|
||||
.showMore { background: var(--lavender); border: 0; border-radius: 13px; padding: 11px; width: 100%; font-weight: 750; font-size: 14px; color: var(--blue); }
|
||||
.savedNotice, .partialNotice { margin-top: 12px; padding: 12px 14px; border-radius: 13px; background: #fff7e9; color: #866529; font-size: 14px; line-height: 1.6; }
|
||||
.benefitStrip { display: flex; align-items: center; gap: 18px; padding: 22px 26px; background: #eeede6; border-radius: 22px; margin: 24px 0 42px; }
|
||||
.benefitIcon { width: 43px; height: 43px; border-radius: 15px; display: grid; place-items: center; background: #faf9f6; color: var(--blue); }
|
||||
.benefitStrip strong { font-size: 15px; font-weight: 800; }
|
||||
.benefitStrip p { margin-top: 4px; font-size: 14px; color: var(--muted); line-height: 1.6; }
|
||||
.benefitStrip > a { display: flex; align-items: center; gap: 9px; margin-left: auto; font-size: 14px; white-space: nowrap; font-weight: 750; color: var(--blue); }
|
||||
.footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 25px 0 34px; border-top: 1px solid var(--line); }
|
||||
.footer > span { font-weight: 800; font-size: 20px; letter-spacing: -.5px; }
|
||||
.footer > span small { display: block; font-weight: 500; font-size: 13px; letter-spacing: 0; color: var(--muted); margin-top: 3px; }
|
||||
.footer > div { display: flex; align-items: center; gap: 22px; font-size: 14px; color: var(--muted); }
|
||||
.footer a { display: flex; gap: 4px; align-items: center; }
|
||||
.footer button { border: 0; background: transparent; font-size: inherit; color: inherit; padding: 7px 0; }
|
||||
.footer > small { font-size: 13px; color: var(--muted); }
|
||||
.mobileNav { display: none; }
|
||||
.demoBanner, .offlineBanner { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #fff1d8; color: #75561f; font-size: 14px; line-height: 1.5; border-radius: 15px; padding: 13px 18px; margin-top: 20px; }
|
||||
.demoBanner button { display: flex; align-items: center; gap: 5px; background: transparent; border: 0; font-size: 14px; font-weight: 750; color: inherit; }
|
||||
.offlineBanner { justify-content: flex-start; background: var(--lavender); color: var(--blue); }
|
||||
.favoritesHero { padding: 55px 0 40px; }
|
||||
.favoritesEmpty { border-radius: 32px; padding: 70px 20px; text-align: center; background: white; border: 1px solid var(--line); margin-bottom: 50px; }
|
||||
.favoritesEmpty .emptyIcon { margin: 0 auto 25px; }
|
||||
.favoritesEmpty h2 { font-size: 23px; }
|
||||
.favoritesEmpty p { font-size: 15px; line-height: 1.8; color: var(--muted); margin: 13px 0 24px; }
|
||||
.favoritesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-bottom: 50px; }
|
||||
.favoriteCard { background: var(--lavender); border-radius: 28px; padding: 22px; }
|
||||
.favoriteCity { display: flex; align-items: center; gap: 5px; font-size: 14px; color: var(--blue-muted); }
|
||||
.favoriteCity button { margin-left: auto; }
|
||||
.favoriteCard h2 { font-size: 23px; letter-spacing: -.5px; line-height: 1.3; margin: 15px 0 8px; color: var(--blue); }
|
||||
.favoriteCard > p { font-size: 15px; line-height: 1.6; color: var(--blue-muted); }
|
||||
.favoriteDepartures { margin-top: 20px; padding-top: 12px; border-top: 1px solid #dce0ee; min-height: 156px; }
|
||||
.favoriteDepartures ul { list-style: none; margin: 0; padding: 0; }
|
||||
.favoriteDepartures li { display: flex; align-items: center; gap: 12px; padding: 8px 0; font-size: 14px; }
|
||||
.favoriteDepartures li > strong { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--blue); }
|
||||
.favoriteDepartures li > span:last-child { margin-left: auto; color: var(--blue-muted); text-align: right; }
|
||||
.favoriteDepartures p { color: var(--blue-muted); font-size: 15px; padding: 18px 0; line-height: 1.7; }
|
||||
.favoriteRoute { display: grid; place-items: center; min-width: 42px; min-height: 36px; padding: 4px 8px; font-weight: 800; border-radius: 10px; color: var(--blue); background: white; }
|
||||
.favoriteStatus { display: flex; align-items: flex-start; gap: 7px; font-size: 13px; color: var(--blue-muted); margin-top: 12px; line-height: 1.7; }
|
||||
.favoriteStatus > .vkuiIcon { margin-top: 3px; }
|
||||
.favoriteLink { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 750; color: var(--blue); padding-top: 20px; border-top: 1px solid #dce0ee; margin-top: 22px; }
|
||||
.aboutPage { padding: 55px 0 50px; }
|
||||
.aboutIntro h1 { font-size: clamp(36px, 4.6vw, 58px); line-height: 1.2; letter-spacing: -.5px; margin: 16px 0; }
|
||||
.aboutIntro h1 span { color: var(--blue); }
|
||||
.aboutIntro > p { color: var(--muted); line-height: 1.8; }
|
||||
.aboutGrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; margin-top: 36px; align-items: start; }
|
||||
.aboutGrid article { padding: 32px; border-radius: var(--card-radius); }
|
||||
.aboutStory { background: white; border: 1px solid var(--line); }
|
||||
.aboutGrid h2 { font-size: 25px; line-height: 1.35; letter-spacing: -.3px; margin: 20px 0 16px; }
|
||||
.aboutGrid p { font-size: 16px; line-height: 1.9; margin: 16px 0; }
|
||||
.aboutStory p { color: var(--muted); }
|
||||
.aboutStory .primaryButton { margin-top: 14px; }
|
||||
.aboutSupport { background: var(--green); color: var(--green-ink); }
|
||||
.aboutSupport > img { transform: rotate(12deg); opacity: .3; }
|
||||
.supportNote { font-size: 14px; line-height: 1.8; padding-top: 20px; margin-top: 24px; border-top: 1px solid #bdd4b6; }
|
||||
.toast, .updateToast { position: fixed; z-index: 50; left: 50%; bottom: 30px; transform: translateX(-50%); max-width: calc(100vw - 32px); background: var(--blue); color: white; border-radius: 18px; padding: 16px 20px; display: flex; align-items: center; gap: 15px; box-shadow: 0 10px 30px #2c467830; font-size: 15px; }
|
||||
.toast button, .updateToast button { border: 0; background: transparent; color: inherit; display: flex; padding: 4px; }
|
||||
.updateToast { bottom: 90px; }
|
||||
.updateToast button { font-weight: 750; font-size: 15px; }
|
||||
.modalBackdrop { position: fixed; inset: 0; z-index: 100; background: #14274b70; backdrop-filter: blur(5px); display: grid; place-items: center; padding: 20px; }
|
||||
.installDialog { position: relative; padding: 34px; border-radius: 30px; max-width: 430px; background: white; }
|
||||
.closeDialog { position: absolute; top: 14px; right: 14px; }
|
||||
.installDialog h2 { font-size: 25px; letter-spacing: -.5px; margin: 20px 0 15px; }
|
||||
.installDialog p { font-size: 16px; line-height: 1.8; margin: 10px 0; }
|
||||
.installDialog .muted { color: var(--muted); font-size: 14px; }
|
||||
.installDialog .primaryButton { margin-top: 14px; }
|
||||
.skipLink { position: fixed; left: 20px; top: 20px; z-index: 200; padding: 15px; border-radius: 10px; background: white; clip-path: inset(100%); }
|
||||
.skipLink:focus { clip-path: none; }
|
||||
.skeletonRows { padding-top: 15px; }
|
||||
.skeletonRow { display: flex; gap: 15px; align-items: center; height: 84px; }
|
||||
.skeletonRow i { width: 46px; height: 46px; border-radius: 14px; background: var(--lavender); }
|
||||
.skeletonRow span { height: 18px; flex: 1; background: var(--lavender); border-radius: 10px; }
|
||||
@media (min-width: 1400px) { .hero h1 { font-size: 62px; } }
|
||||
@media (max-width: 1050px) { .app { padding: 0 30px; } .workspace { grid-template-columns: 300px minmax(0, 1fr); gap: 18px; } .boardCard { padding: 24px; } .heroNote { width: 250px; } .heroNote strong { font-size: 23px; } .header { gap: 12px; } .desktopNav a { padding: 10px 12px; font-size: 15px; } .webTag { display: none; } }
|
||||
@media (max-width: 760px) {
|
||||
.app { padding: 0 20px; padding-bottom: 105px; }
|
||||
.header { height: 83px; }
|
||||
.brand img { width: 38px; height: 38px; }
|
||||
.brand > span:first-of-type { font-size: 25px; }
|
||||
.desktopNav { display: none; }
|
||||
.installButton { padding: 10px 14px; font-size: 14px; }
|
||||
.hero { padding: 36px 0 29px; }
|
||||
.hero .eyebrow { font-size: 13px; letter-spacing: 1px; }
|
||||
.hero h1 { font-size: clamp(32px, 6.7vw, 48px); letter-spacing: -.3px; line-height: 1.2; margin: 0 0 16px; }
|
||||
.heroCopy > p { font-size: 15px; line-height: 1.8; }
|
||||
.heroNote { display: none; }
|
||||
.desktopBreak { display: none; }
|
||||
.workspace { grid-template-columns: 1fr; gap: 18px; }
|
||||
.stopsCard { padding: 22px 18px 0; border-radius: 26px; }
|
||||
.sectionHeading h2 { font-size: 24px; }
|
||||
.sectionDescription { margin-bottom: 18px; }
|
||||
.stopsList { max-height: 244px; }
|
||||
.stopCopy strong { font-size: 16px; }
|
||||
.stopsFooter { font-size: 14px; }
|
||||
.boardCard { border-radius: 26px; padding: 22px 20px; min-height: 400px; }
|
||||
.boardCard h2 { font-size: 27px; }
|
||||
.boardHeading .eyebrow { font-size: 14px; letter-spacing: 0; }
|
||||
.boardDirection { font-size: 15px; }
|
||||
.boardMeta { font-size: 14px; gap: 8px; }
|
||||
.routeFilters button { font-size: 14px; min-height: 40px; }
|
||||
.departureRow { gap: 12px; min-height: 88px; }
|
||||
.departureName strong { font-size: 15px; }
|
||||
.transportLabel, .departureTime span { font-size: 14px; }
|
||||
.departureTime strong { font-size: 26px; }
|
||||
.routeNumber { min-width: 48px; height: 48px; }
|
||||
.boardFooter { align-items: flex-start; }
|
||||
.boardFooter > span { font-size: 13px; line-height: 1.6; max-width: 210px; }
|
||||
.boardEmpty { min-height: 265px; }
|
||||
.benefitStrip { margin: 18px 0 30px; padding: 20px; gap: 13px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.benefitStrip > div { flex: 1; flex-basis: calc(100% - 56px); }
|
||||
.benefitStrip > a { margin-left: 56px; font-size: 14px; }
|
||||
.benefitStrip strong { font-size: 15px; }
|
||||
.benefitStrip p { font-size: 14px; }
|
||||
.footer { flex-wrap: wrap; padding: 24px 0 20px; }
|
||||
.footer > small { display: none; }
|
||||
.footer > div { gap: 18px; flex-wrap: wrap; }
|
||||
.mobileNav { position: fixed; z-index: 30; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-evenly; gap: 16px; border-top: 1px solid #e0e3ed; background: #fffffff0; backdrop-filter: blur(14px); padding: 11px 20px max(11px, env(safe-area-inset-bottom)); }
|
||||
.mobileNav a { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; font-weight: 700; color: var(--muted); padding: 0 12px; }
|
||||
.mobileNav a.active { color: var(--blue); }
|
||||
.toast { bottom: 110px; font-size: 14px; width: max-content; padding: 14px 16px; }
|
||||
.updateToast { bottom: 145px; flex-wrap: wrap; font-size: 14px; width: max-content; }
|
||||
.favoritesHero { padding: 38px 0 30px; }
|
||||
.favoritesHero h1 { font-size: 37px; letter-spacing: -.5px; }
|
||||
.favoritesGrid { grid-template-columns: 1fr; gap: 14px; }
|
||||
.favoritesEmpty { padding: 45px 20px; }
|
||||
.favoritesEmpty h2 { font-size: 21px; }
|
||||
.aboutPage { padding: 38px 0 32px; }
|
||||
.aboutGrid { grid-template-columns: 1fr; gap: 18px; margin-top: 28px; }
|
||||
.aboutGrid article { padding: 24px; border-radius: 26px; }
|
||||
.aboutGrid h2 { font-size: 23px; }
|
||||
.demoBanner { flex-wrap: wrap; font-size: 14px; }
|
||||
.offlineBanner { font-size: 14px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.departureRow { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 7px 12px; padding: 18px 0; }
|
||||
.departureRow .routeNumber { grid-column: 1; grid-row: 1 / 3; align-self: start; margin-top: 4px; }
|
||||
.departureName { grid-column: 2; }
|
||||
.departureTime { grid-column: 2; flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 8px; margin-left: 0; padding-left: 0; }
|
||||
}
|
||||
@media (max-width: 360px) { .app { padding: 0 14px 105px; } .hero h1 { font-size: 31px; } .boardCard { padding: 20px 16px; } .departureRow { gap: 9px; } .departureName strong { font-size: 14px; } .departureTime strong { font-size: 24px; } .brand { gap: 5px; } .footer > div { gap: 12px; } }
|
||||
@include features.components;
|
||||
@include motion.components;
|
||||
12
src/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type City = { id: number; name: string; slug: string };
|
||||
export type Stop = { id: number; name: string; direction: string; routesCount: number };
|
||||
export type Route = { id: number; number: string; name: string };
|
||||
export type Departure = { routeId: number; routeNumber: string; direction: string; scheduledTime: string; scheduledAt: string; note?: string; tripKey?: string };
|
||||
export type Board = { departures: Departure[]; routes: { id: number; number: string; name: string }[]; updatedAt: string; partial: boolean; date?: string; isHoliday?: boolean; holidayName?: string | null };
|
||||
export type Timetable = Board & { date: string; isHoliday: boolean; holidayName: string | null };
|
||||
export type SavedStop = Stop & { key: string; cityId: number; cityName: string; mode: 'live' | 'demo'; addedAt: number };
|
||||
export type Snapshot<T> = { data: T; savedAt: string; saved: boolean };
|
||||
export type QuickTrip = { id: string; name: string; mode: 'live' | 'demo'; cityId: number; cityName: string; stop: Stop; route: Route; createdAt: number };
|
||||
export type NearbyStop = Stop & { distance: number };
|
||||
export type ServiceAlert = { id: number; cityId: number; title: string; message: string; type: 'info' | 'delay' | 'detour' | 'cancellation'; routeNumbers: string[]; routeIds: number[]; isCityWide: boolean; startTime: string; endTime: string | null };
|
||||
export type Journey = { route: Route; selectedStopId: number; stops: { id: number; sequence: number; name: string; scheduledTime: string; scheduledAt: string }[]; note: string; tripKey?: string; updatedAt: string };
|
||||
73
test/api.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ApiError, createRideBusClient, normalizeDepartures, parseRequest } from '../server/ridebus.mjs';
|
||||
import { apiHandler } from '../server/index.mjs';
|
||||
|
||||
test('only supported resources and positive city/stop identifiers are accepted', () => {
|
||||
assert.deepEqual(parseRequest(new URLSearchParams('resource=cities')), { resource: 'cities' });
|
||||
assert.deepEqual(parseRequest(new URLSearchParams('resource=departures&cityId=2&stopId=15')), { resource: 'departures', cityId: 2, stopId: 15 });
|
||||
for (const input of ['resource=https://evil.example', 'resource=stops', 'resource=stops&cityId=-1', 'resource=departures&cityId=1&stopId=NaN', 'resource=stops&cityId=9007199254740992']) {
|
||||
assert.throws(() => parseRequest(new URLSearchParams(input)), error => error.status === 400);
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduled departures exclude historical delay and retain absolute expiration times', () => {
|
||||
const now = Date.parse('2026-10-06T20:55:00Z');
|
||||
const data = normalizeDepartures({ routes: [{ id: 5, route_number: '10', name: 'Вокзал → Центр', arrivals: [
|
||||
{ scheduledTime: '0:05:00', minutesUntil: 14, delay: 4 },
|
||||
{ scheduledTime: '23:56', minutesUntil: 1, delay: 0 },
|
||||
{ scheduledTime: '25:12', minutesUntil: 12, delay: 0 },
|
||||
] }, { id: 6, route_number: '4', name: 'Больница', error: 'unavailable' }] }, now);
|
||||
assert.equal(data.departures.length, 2);
|
||||
assert.equal(data.departures[0].scheduledTime, '23:56');
|
||||
assert.equal(data.departures[1].scheduledTime, '00:05');
|
||||
assert.equal(data.departures[1].scheduledAt, '2026-10-06T21:05:00.000Z');
|
||||
assert.equal(data.partial, true);
|
||||
assert.equal(data.routes.length, 2);
|
||||
});
|
||||
|
||||
test('a negative scheduled wait is skipped rather than shown as a future bus', () => {
|
||||
const data = normalizeDepartures({ routes: [{ id: 1, route_number: '1', name: 'Центр', arrivals: [
|
||||
{ scheduledTime: '10:00', minutesUntil: 3, delay: 5 },
|
||||
] }] });
|
||||
assert.equal(data.departures.length, 0);
|
||||
});
|
||||
|
||||
test('server cache isolates cities, coalesces concurrent requests, and sends credentials only upstream', async () => {
|
||||
const calls = [];
|
||||
const client = createRideBusClient({ apiKey: 'private-test-key', baseUrl: 'https://api.example/', fetchData: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return { ok: true, json: async () => [{ id: 1, name: `City ${options.headers['X-City-Id']}`, routes_count: '2' }] };
|
||||
} });
|
||||
const city1 = { resource: 'stops', cityId: 1 };
|
||||
const city2 = { resource: 'stops', cityId: 2 };
|
||||
const [a, b] = await Promise.all([client.get(city1), client.get(city1)]);
|
||||
const c = await client.get(city2);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(a, b);
|
||||
assert.notEqual(a[0].name, c[0].name);
|
||||
assert.equal(calls[0].options.headers['X-API-Key'], 'private-test-key');
|
||||
assert.equal(calls[1].options.headers['X-City-Id'], '2');
|
||||
assert.equal(JSON.stringify(a).includes('private-test-key'), false);
|
||||
});
|
||||
|
||||
test('missing configuration and upstream failures produce safe API errors', async () => {
|
||||
await assert.rejects(createRideBusClient({ apiKey: '' }).get({ resource: 'cities' }), error => error.status === 503);
|
||||
await assert.rejects(createRideBusClient({ apiKey: 'key', fetchData: async () => { throw new Error('private-key=secret'); } }).get({ resource: 'cities' }), error => error.status === 502 && !error.message.includes('secret'));
|
||||
});
|
||||
|
||||
function response() {
|
||||
return { headers: {}, status: 200, body: '', setHeader(k, v) { this.headers[k] = v; }, writeHead(code) { this.status = code; return this; }, end(body = '') { this.body = body; } };
|
||||
}
|
||||
test('HTTP API refuses writes and hides internal exception details', async () => {
|
||||
const handler = apiHandler({ get: async () => { throw new Error('secret details'); } });
|
||||
const write = response();
|
||||
assert.equal(await handler({ method: 'POST', url: '/api/data?resource=cities' }, write), true);
|
||||
assert.equal(write.status, 405);
|
||||
const failure = response();
|
||||
await handler({ method: 'GET', url: '/api/data?resource=cities' }, failure);
|
||||
assert.equal(failure.status, 502);
|
||||
assert.deepEqual(JSON.parse(failure.body), { error: 'data_unavailable' });
|
||||
assert.equal(failure.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(await handler({ method: 'GET', url: '/' }, response()), false);
|
||||
});
|
||||
126
test/features.test.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeAlerts, normalizeNearby } from '../server/features.mjs';
|
||||
import { createRideBusClient, normalizeDepartures, parseRequest } from '../server/ridebus.mjs';
|
||||
import { journeyStops, routeDepartures, tripIdentity } from '../server/timetable.mjs';
|
||||
import { relevantAlerts } from '../src/lib/alerts.ts';
|
||||
import { groupByHour } from '../src/lib/timetable.ts';
|
||||
import { journeyLink } from '../src/lib/links.ts';
|
||||
|
||||
const at = '2026-10-07T09:00:00.000Z';
|
||||
const baseAlert = { id: 1, city_id: 1, is_active: true, title: 'Отмена', message: 'Вокзал 12:00 — отменён\nЦентр 12:15 — отменён', start_time: at, end_time: '2026-10-07T10:00:00.000Z' };
|
||||
test('alerts normalize arrays before legacy fields, deduplicate and preserve cancellation details', () => {
|
||||
const alerts = normalizeAlerts([
|
||||
{ ...baseAlert, route_id: null, route_number: null, route_numbers: ['№27', '2', '27'], route_ids: [5, 6], alert_type: 'cancellation' },
|
||||
{ ...baseAlert },
|
||||
{ ...baseAlert, id: 2, route_id: 7, route_number: ' 112с ', route_numbers: [], route_ids: [] },
|
||||
{ ...baseAlert, id: 3, city_id: 2 },
|
||||
{ ...baseAlert, id: 4, is_active: false },
|
||||
{ ...baseAlert, id: 5, route_numbers: null, route_ids: null },
|
||||
], 1);
|
||||
assert.deepEqual(alerts.map(a => a.id), [1, 2, 5]);
|
||||
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
|
||||
assert.equal(alerts[0].message, baseAlert.message);
|
||||
assert.equal(alerts[0].isCityWide, false);
|
||||
assert.deepEqual(alerts[1].routeIds, [7]);
|
||||
assert.deepEqual(alerts[1].routeNumbers, ['112С']);
|
||||
assert.equal(alerts[2].isCityWide, true);
|
||||
for (const invalid of [{ route_numbers: '2,27' }, { route_ids: [null] }, { route_numbers: [''] }]) {
|
||||
assert.throws(() => normalizeAlerts([{ ...baseAlert, ...invalid }], 1), e => e.status === 502);
|
||||
}
|
||||
});
|
||||
|
||||
test('cached alerts expire at exact boundaries and target either route id or number', () => {
|
||||
const alerts = normalizeAlerts([{ ...baseAlert, route_id: 7, route_number: '112с' }], 1);
|
||||
const now = Date.parse(at);
|
||||
assert.equal(relevantAlerts(alerts, 1, now - 1).length, 0);
|
||||
assert.equal(relevantAlerts([...alerts, ...alerts], 1, now, [{ id: 99, number: '№112С' }]).length, 1);
|
||||
assert.equal(relevantAlerts(alerts, 1, now, [{ id: 7, number: '5' }]).length, 1);
|
||||
assert.equal(relevantAlerts(alerts, 1, now, [{ id: 8, number: '5' }]).length, 0);
|
||||
assert.equal(relevantAlerts(alerts, 2, now).length, 0);
|
||||
assert.equal(relevantAlerts(alerts, 1, now + 3600000).length, 0);
|
||||
assert.equal(relevantAlerts([{ ...alerts[0], startTime: 'bad date' }], 1, now).length, 0);
|
||||
});
|
||||
|
||||
test('location and journey requests accept only bounded coordinates, identifiers and times', () => {
|
||||
assert.deepEqual(parseRequest(new URLSearchParams('resource=nearby&cityId=1&lat=0&lon=0')), { resource: 'nearby', cityId: 1, lat: 0, lon: 0, radius: 1000 });
|
||||
const journey = { resource: 'journey', cityId: '1', stopId: '2', routeId: '3', at };
|
||||
assert.equal(parseRequest(new URLSearchParams(journey)).at, at);
|
||||
assert.equal(parseRequest(new URLSearchParams({ ...journey, at: '2100-12-31T09:00:00.000Z' })).at, '2100-12-31T09:00:00.000Z');
|
||||
assert.equal(parseRequest(new URLSearchParams({ ...journey, at: '2000-01-01T21:00:00.000Z' })).at, '2000-01-01T21:00:00.000Z');
|
||||
for (const params of [
|
||||
{ resource: 'nearby', cityId: '1', lat: '91', lon: '28' },
|
||||
{ resource: 'nearby', cityId: '1', lat: '55', lon: 'https://evil/' },
|
||||
{ resource: 'nearby', cityId: '1', lat: '55', lon: '28', radius: '2001' },
|
||||
{ ...journey, routeId: '-1' }, { ...journey, at: '2026-02-30' }, { ...journey, tripKey: '../../x' },
|
||||
]) assert.throws(() => parseRequest(new URLSearchParams(params)), e => e.status === 400);
|
||||
});
|
||||
|
||||
test('nearby search does not cache coordinates/results and exposes only normalized stops', async () => {
|
||||
let calls = 0;
|
||||
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => {
|
||||
calls++; assert.equal(url.pathname, '/api/v1/stops/nearby');
|
||||
return { ok: true, json: async () => [{ id: 1, name: 'Вокзал', distance_meters: '80.3', lat: 55 }, { id: 2, name: 'Центр', distance_meters: 12 }] };
|
||||
} });
|
||||
const input = { resource: 'nearby', cityId: 1, lat: 55, lon: 28, radius: 1000 };
|
||||
const a = await client.get(input); await client.get(input);
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(a.map(stop => stop.distance), [12, 80]);
|
||||
assert.equal(JSON.stringify(a).includes('lat'), false);
|
||||
assert.throws(() => normalizeNearby([null]), e => e.status === 502);
|
||||
assert.throws(() => normalizeNearby([{ id: 1, name: 'Вокзал', distance_meters: -1 }]), e => e.status === 502);
|
||||
});
|
||||
|
||||
const route = { id: 3, number: '112С', name: 'Вокзал → Центр' };
|
||||
const stops = [
|
||||
{ stop_id: 1, sequence: 1, name: 'Вокзал', time_offset_minutes: 0 },
|
||||
{ stop_id: 2, sequence: 2, name: 'Площадь', time_offset_minutes: 10 },
|
||||
{ stop_id: 3, sequence: 3, name: 'Центр', time_offset_minutes: 25 },
|
||||
];
|
||||
test('journey times follow the selected full, shortened and reverse trip across midnight', () => {
|
||||
for (const [trip, ids, times] of [
|
||||
[{ departureTime: '23:50' }, [1, 2, 3], ['23:50', '00:00', '00:15']],
|
||||
[{ departureTime: '08:00', startStopId: 2, endStopId: 3 }, [2, 3], ['08:00', '08:15']],
|
||||
[{ departureTime: '08:00', startStopId: 3, endStopId: 1 }, [3, 2, 1], ['08:00', '08:15', '08:25']],
|
||||
]) {
|
||||
const result = journeyStops(stops, { trip, serviceDate: '2026-10-06' });
|
||||
assert.deepEqual(result.map(stop => stop.id), ids);
|
||||
assert.deepEqual(result.map(stop => stop.scheduledTime), times);
|
||||
if (trip.departureTime === '23:50') assert.equal(result[2].scheduledAt, '2026-10-06T21:15:00.000Z');
|
||||
}
|
||||
});
|
||||
|
||||
test('journey adapter distinguishes trips sharing an arrival time and caches by city', async () => {
|
||||
const trips = [{ departureTime: '08:00' }, { departureTime: '08:10', startStopId: 2 }];
|
||||
const calls = [];
|
||||
const client = createRideBusClient({ apiKey: 'key', fetchData: async (url, options) => {
|
||||
calls.push(options.headers['X-City-Id']);
|
||||
return { ok: true, json: async () => url.pathname.endsWith('/schedule/today')
|
||||
? { schedule: { trips } } : { id: 3, route_number: '112С', name: route.name, stops: stops.map(stop => ({ ...stop, id: stop.stop_id })) } };
|
||||
} });
|
||||
const input = { resource: 'journey', cityId: 1, stopId: 2, routeId: 3, at: '2026-10-07T05:10:00.000Z' };
|
||||
await assert.rejects(client.get(input), e => e.status === 409 && e.code === 'journey_ambiguous');
|
||||
const data = await client.get({ ...input, tripKey: tripIdentity(trips[1], '2026-10-07') });
|
||||
assert.deepEqual(data.stops.map(stop => stop.id), [2, 3]);
|
||||
assert.equal(data.stops[1].scheduledTime, '08:25');
|
||||
await client.get({ ...input, cityId: 2, tripKey: data.tripKey });
|
||||
assert.ok(calls.includes('2'));
|
||||
await assert.rejects(client.get({ ...input, at: '2026-10-07T12:00:00.000Z' }), e => e.code === 'journey_not_found');
|
||||
});
|
||||
|
||||
test('ETA trip identity is the same as full timetable for an overnight arrival', () => {
|
||||
const trip = { departureTime: '23:50:00', startStopId: 1, endStopId: 3, note: 'Рейс' };
|
||||
const full = routeDepartures(route, stops, { schedule: { trips: [trip] } }, 3, '2026-10-06').departures[0];
|
||||
const eta = normalizeDepartures({ routes: [{ id: 3, route_number: '112С', name: route.name, arrivals: [{ trip, scheduledTime: '00:15:00', minutesUntil: 20, delay: 0 }] }] }, Date.parse('2026-10-06T20:55:00Z')).departures[0];
|
||||
assert.equal(eta.tripKey, full.tripKey);
|
||||
});
|
||||
|
||||
test('hour grid groups sorted Belarus time and trip links retain date/filter identity', () => {
|
||||
const departures = ['2026-10-06T22:15:00.000Z', '2026-10-06T21:30:00.000Z', '2026-10-06T21:00:00.000Z'].map(scheduledAt => ({ routeId: 3, routeNumber: '112С', direction: 'Центр', scheduledAt, tripKey: 'unique' }));
|
||||
const groups = groupByHour(departures);
|
||||
assert.deepEqual(groups.map(group => [group.hour, group.departures.length]), [['00', 2], ['01', 1]]);
|
||||
const params = new URL(journeyLink('demo', 1, 2, departures[0], 'day', '2026-10-07', 3), 'http://localhost').searchParams;
|
||||
assert.equal(params.get('date'), '2026-10-07');
|
||||
assert.equal(params.get('filter'), '3');
|
||||
assert.equal(params.get('tripKey'), 'unique');
|
||||
});
|
||||
38
test/stopGroups.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countLabel, directionLabel, groupStops } from '../src/lib/stopGroups.ts';
|
||||
|
||||
test('names are grouped like the landing while direction IDs remain independent', () => {
|
||||
const groups = groupStops([
|
||||
{ id: 1, name: ' Площадь Свободы ', direction: 'В центр', routesCount: 2 },
|
||||
{ id: 2, name: 'площадь свободы', direction: 'Из центра', routesCount: 3 },
|
||||
{ id: 1, name: 'Площадь Свободы', direction: 'Duplicate ID', routesCount: 2 },
|
||||
{ id: 3, name: 'Автовокзал', direction: '', routesCount: 1 },
|
||||
]);
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(groups[0].name, 'Автовокзал');
|
||||
assert.equal(groups[1].name, 'Площадь Свободы');
|
||||
assert.deepEqual(groups[1].stops.map(stop => stop.id), [1, 2]);
|
||||
});
|
||||
|
||||
test('direction labels distinguish unlabeled and repeated directions', () => {
|
||||
const stops = [
|
||||
{ id: 1, name: 'Вокзал', direction: 'В центр', routesCount: 2 },
|
||||
{ id: 2, name: 'Вокзал', direction: 'В центр', routesCount: 2 },
|
||||
{ id: 3, name: 'Вокзал', direction: '', routesCount: 2 },
|
||||
];
|
||||
const group = groupStops(stops)[0];
|
||||
assert.equal(directionLabel(group, stops[0], 0), 'В центр · 1');
|
||||
assert.equal(directionLabel(group, stops[1], 1), 'В центр · 2');
|
||||
assert.equal(directionLabel(group, stops[2], 2), 'Направление 3');
|
||||
});
|
||||
|
||||
test('the list retains more than eighty stops and uses correct count labels', () => {
|
||||
const stops = Array.from({ length: 120 }, (_, index) => ({ id: index + 1, name: `Остановка ${index + 1}`, direction: '', routesCount: 1 }));
|
||||
assert.equal(groupStops(stops).length, 120);
|
||||
const forms = ['остановка', 'остановки', 'остановок'];
|
||||
assert.equal(countLabel(1, forms), '1 остановка');
|
||||
assert.equal(countLabel(2, forms), '2 остановки');
|
||||
assert.equal(countLabel(11, forms), '11 остановок');
|
||||
assert.equal(countLabel(21, forms), '21 остановка');
|
||||
});
|
||||
87
test/timetable.test.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRideBusClient, parseRequest } from '../server/ridebus.mjs';
|
||||
import { routeDepartures } from '../server/timetable.mjs';
|
||||
import { formatWait, isTravelDate, localDate } from '../src/lib/time.ts';
|
||||
|
||||
const route = { id: 1, number: '112С', name: 'Вокзал → Центр' };
|
||||
const stops = [
|
||||
{ stop_id: 1, sequence: 1, time_offset_minutes: 0 },
|
||||
{ stop_id: 2, sequence: 2, time_offset_minutes: 10 },
|
||||
{ stop_id: 3, sequence: 3, time_offset_minutes: 25 },
|
||||
];
|
||||
|
||||
test('waiting time uses hours and minutes and dates respect Belarus midnight', () => {
|
||||
for (const [minutes, label] of [[0, 'сейчас'], [-1, 'сейчас'], [1, 'через 1 мин'], [59, 'через 59 мин'], [60, 'через 1 ч'], [61, 'через 1 ч 1 мин'], [121, 'через 2 ч 1 мин'], [180, 'через 3 ч']]) {
|
||||
assert.equal(formatWait(minutes), label);
|
||||
}
|
||||
assert.equal(localDate(Date.parse('2026-10-06T21:01:00Z')), '2026-10-07');
|
||||
assert.equal(isTravelDate('2026-02-30'), false);
|
||||
});
|
||||
|
||||
test('timetable requests require actual calendar dates and safe IDs', () => {
|
||||
assert.deepEqual(parseRequest(new URLSearchParams('resource=timetable&cityId=1&stopId=2&date=2026-10-07')), { resource: 'timetable', cityId: 1, stopId: 2, date: '2026-10-07' });
|
||||
for (const date of ['', '2026-02-30', '2026-13-01', '2026-10-07&url=evil', '0000-01-01']) {
|
||||
assert.throws(() => parseRequest(new URLSearchParams({ resource: 'timetable', cityId: '1', stopId: '2', date })), error => error.status === 400);
|
||||
}
|
||||
});
|
||||
|
||||
test('full timetable respects weekday exceptions and shortened trips, and offsets from the trip start', () => {
|
||||
const result = routeDepartures(route, stops, { schedule: { trips: [
|
||||
{ departureTime: '08:00', startStopId: 2, endStopId: 3, daysOfWeek: [3], note: 'Укороченный рейс' },
|
||||
{ departureTime: '09:00', endStopId: 1 },
|
||||
{ departureTime: '10:00', exceptDaysOfWeek: [3] },
|
||||
{ departureTime: '11:00', daysOfWeek: [4] },
|
||||
{ departureTime: '12:00', startStopId: 999 },
|
||||
] } }, 3, '2026-10-07');
|
||||
assert.equal(result.departures.length, 1);
|
||||
assert.equal(result.departures[0].scheduledTime, '08:15');
|
||||
assert.equal(result.departures[0].scheduledAt, '2026-10-07T05:15:00.000Z');
|
||||
assert.equal(result.departures[0].note, 'Укороченный рейс');
|
||||
assert.equal(result.partial, true);
|
||||
});
|
||||
|
||||
test('reverse trips and legacy schedules produce correct times across midnight', () => {
|
||||
const reverse = routeDepartures(route, stops, { schedule: { trips: [{ departureTime: '08:00', startStopId: 3, endStopId: 1 }] } }, 2, '2026-10-07');
|
||||
assert.equal(reverse.departures[0].scheduledTime, '08:15');
|
||||
const legacy = routeDepartures(route, stops, { schedule: { departure_times: ['23:50:00'] } }, 3, '2026-10-06');
|
||||
assert.equal(legacy.departures[0].scheduledTime, '00:15');
|
||||
assert.equal(legacy.departures[0].scheduledAt, '2026-10-06T21:15:00.000Z');
|
||||
});
|
||||
|
||||
test('adapter includes previous-day overnight trips, caches by date/city, and reports failed routes', async () => {
|
||||
const calls = [];
|
||||
const client = createRideBusClient({ apiKey: 'secret', fetchData: async (url, options) => {
|
||||
calls.push(`${options.headers['X-City-Id']}:${url.pathname}${url.search}`);
|
||||
if (url.pathname.includes('/routes/2/')) return { ok: false, status: 500 };
|
||||
const data = url.pathname.includes('/schedule/today')
|
||||
? { schedule: { departure_times: ['08:00', '23:50'] }, isHoliday: true, holidayName: 'Праздник' }
|
||||
: url.pathname.endsWith('/stops') ? stops
|
||||
: { routes: [{ id: 1, route_number: '112С', name: 'Центр' }, { id: 2, route_number: '2', name: 'Больница' }] };
|
||||
return { ok: true, status: 200, json: async () => data };
|
||||
} });
|
||||
const input = { resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' };
|
||||
const data = await client.get(input);
|
||||
assert.deepEqual(data.departures.map(item => item.scheduledTime), ['00:15', '08:25']);
|
||||
assert.equal(data.partial, true);
|
||||
assert.equal(data.isHoliday, true);
|
||||
assert.equal(JSON.stringify(data).includes('secret'), false);
|
||||
const count = calls.length;
|
||||
await client.get(input);
|
||||
assert.equal(calls.length, count);
|
||||
await client.get({ ...input, date: '2026-10-08' });
|
||||
assert.ok(calls.some(call => call.includes('date=2026-10-08')));
|
||||
await client.get({ ...input, cityId: 2 });
|
||||
assert.ok(calls.some(call => call.startsWith('2:')));
|
||||
});
|
||||
|
||||
test('missing route schedule is empty while total upstream failure remains an error', async () => {
|
||||
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => {
|
||||
if (url.pathname.includes('/schedule/today')) return { ok: false, status: 404 };
|
||||
return { ok: true, json: async () => url.pathname.endsWith('/stops') ? stops : { routes: [{ id: 1, route_number: '1', name: 'Центр' }] } };
|
||||
} });
|
||||
const data = await client.get({ resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' });
|
||||
assert.deepEqual(data.departures, []);
|
||||
assert.equal(data.partial, false);
|
||||
await assert.rejects(createRideBusClient({ apiKey: 'key', fetchData: async () => ({ ok: false, status: 500 }) }).get({ resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' }), error => error.status === 502);
|
||||
});
|
||||
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/react"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
39
vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['logo.svg', 'star.svg', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: 'RideBus — расписание транспорта',
|
||||
short_name: 'RideBus',
|
||||
description: 'Остановки, ближайшие отправления и избранное',
|
||||
lang: 'ru',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#faf9f6',
|
||||
theme_color: '#f3f3fa',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,woff2,png,svg,webmanifest}'],
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
// API snapshots are kept in IndexedDB, keyed by city and stop.
|
||||
// The service worker only caches the application shell.
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: { target: 'es2022' },
|
||||
});
|
||||