initial commit

This commit is contained in:
2026-10-07 01:48:32 +03:00
commit 664421662c
70 changed files with 11333 additions and 0 deletions

12
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
.env
.env.*
!.env.example
playwright-report/
test-results/
.playwright/
*.tsbuildinfo
.DS_Store

17
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/icon-maskable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

37
public/logo.svg Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
qa/carousel-fade-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
qa/carousel-live-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>;
}

View 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
View 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>;
}

View 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>;
}

View 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
View 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" />;
}

View 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>;
}

View 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>}
</>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>}
</>;
}

View 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>;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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' },
});