feat: add new components for departures, discovery, and navigation

- Implemented SCSS styles for compact board in departures.
- Created styles for feature discovery modal and its elements.
- Added navigation styles for desktop and mobile views.
- Developed FeatureDiscovery component with guided steps for user onboarding.
- Introduced freshness utility to manage data freshness based on timestamps.
- Added tests for freshness utility to ensure correct behavior across various scenarios.
This commit is contained in:
2026-10-07 21:51:05 +03:00
parent 81b2c7d675
commit 1492f179eb
35 changed files with 830 additions and 101 deletions

View File

@@ -2,9 +2,19 @@
## Возможности
- Четыре раздела: «Главная», «Остановки», «Избранное», «Настройки».
На главной компактный выбор города и остановки; полный каталог открывается
отдельно или в окне выбора. Общий список и поиск рядом переключаются,
чтобы не занимать экран двумя списками. На телефоне выбор остановки
закрывает окно и плавно переводит к отправлениям, в том числе для выбранной даты.
- Знакомство при первом посещении: пять подсказок с подсветкой настоящих
функций главной страницы. Можно пропустить, пройти с клавиатуры или повторить
из настроек. Завершение/пропуск сохраняются в этом браузере.
- Города из RideBus API, поиск остановок без повторяющихся названий и выбор направления чипами в карточке отправлений.
- Ближайшие отправления и полное расписание остановки на выбранную дату,
переключение направлений и фильтр по маршруту.
переключение направлений и фильтр по маршруту. В компактной карточке
название и закладка находятся рядом, кнопка «+» сохраняет поездку,
а на телефоне фильтры маршрутов прокручиваются горизонтально.
- Полное расписание в сетке по часам без скрытых рейсов; переход к ближайшему
времени и открытие конкретного рейса нажатием на отправление.
- «Мои поездки»: город, остановка, направление и маршрут в одной карточке.
@@ -37,7 +47,21 @@
Эффекты отключаются при системной настройке уменьшения движения.
- Просмотр ранее загруженных данных без интернета. Кеш разделён по городам,
остановкам и режиму работы; время сохранения видно в интерфейсе.
Отключение сети показывает один короткий общий статус. Свежие снимки
не получают предупреждение об устаревании только из-за потери соединения.
Предупреждение появляется по времени последнего обновления: от 30 минут
для ближайших отправлений, 24 часов для расписания на дату/рейса и 15 минут
для сообщений о движении. Это пороги напоминания об обновлении, а не гарантия
неизменности расписания. Ошибки обновления онлайн и частичные результаты
продолжают обозначаться отдельно.
- Установка PWA, локальные шрифты, иконки, офлайн-оболочка и предложение обновления.
- Раздел «Настройки» в основной навигации, нижней панели на телефоне и футере.
Светлая, тёмная и системная темы применяются ко всем экранам. По умолчанию
оформление следует теме устройства и реагирует на её изменения.
Выбор сохраняется в этом браузере, синхронизируется между его вкладками
и доступен без сети. Сохранённая тема применяется ещё до запуска React.
При заблокированном хранилище переключение работает до закрытия вкладки;
приложение сообщает, что сохранить выбор не удалось.
- Отдельный демонстрационный режим `/?demo=1` с явно помеченными примерами.
Кнопка «Посмотреть пример» есть только при `npm run dev`; в релизной сборке
и Docker её нет, включая экран ошибки загрузки. Прямая ссылка на демонстрацию
@@ -190,6 +214,11 @@ docker compose down
- `src/components/TripCarousel.tsx` — адаптивная карусель с кнопками, свайпами и клавиатурой.
- `src/components/ShareDeparture.tsx` — отправка/копирование ссылки на конкретное отправление.
- `src/components/StopList.tsx` — общие строки и контейнер для обоих списков остановок.
- `src/components/Settings.tsx`, `src/lib/theme.ts` — выбор темы и её сохранение.
- `src/components/FeatureDiscovery.tsx`, `src/_discovery.scss` — подсказки первого входа.
- `src/_navigation.scss`, `src/_departures.scss` — разделы, компактный выбор и отправления.
- `src/lib/freshness.ts` — возраст данных без привязки к наличию сети.
- `src/_tokens.scss` — цвета светлой и тёмной тем; `src/_settings.scss` — настройки.
- `src/components/HourlyTimetable.tsx`, `RoutePage.tsx` — почасовая сетка и конкретный рейс.
- `src/components/ServiceAlerts.tsx`, `NearbyStops.tsx` — изменения и поиск рядом.
- `src/lib/storage.ts` — версия 2 базы; существующие снимки/избранное сохраняются при обновлении.
@@ -231,6 +260,12 @@ Playwright проверяет desktop и экран iPhone 13 в Chromium: по
отправку/копирование/отмену и ручной резервный способ поделиться рейсом,
одинаковое оформление списков остановок,
затухание края карусели и отсутствие кнопок демо в релизном интерфейсе,
выбор/сохранение темы, системные изменения, синхронизацию вкладок,
первое отображение до запуска JavaScript-приложения и настройки без сети,
четыре раздела, компактность выбора на ширинах 320–1440 px,
выбор остановки с прокруткой и уменьшением движения, переход на нужную дату,
один статус при отключении сети и предупреждение после реального старения данных,
первый вход с подсветкой, повторное обучение и подсказки на экране 320 × 568,
разделение городов, честную ошибку API и настоящий офлайн-перезапуск
через service worker, включая расписание на конкретную дату и рейс.
Это эмуляция размера экрана,

View File

@@ -1,9 +1,9 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
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 page.goto('/stops?demo=1');
await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1');
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
await expect(page.locator('.stopItem')).toHaveCount(1);
@@ -43,8 +43,10 @@ test('stop search, route filter and city-scoped favorites survive reload', async
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 page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
const picker = page.getByRole('dialog', { name: 'Выбрать остановку', exact: true });
await picker.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
await picker.getByRole('button', { name: /Площадь Строителей/ }).click();
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
await expect(page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true })).toBeVisible();
expect(errors).toEqual([]);
@@ -58,7 +60,7 @@ test('every grouped stop is visible without an eighty-stop cap', async ({ page }
: 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 page.goto('/stops');
await expect(page.locator('.stopItem')).toHaveCount(115);
await expect(page.locator('.stopsFooter')).toContainText('115 остановок');
await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
@@ -91,7 +93,8 @@ test('installed service worker serves shell, favorites and saved departures offl
await context.setOffline(true);
await page.reload();
await expect(page.locator('.offlineBanner')).toBeVisible();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
await expect(page.locator('.offlineBanner')).toBeVisible();
await expect(page.locator('.savedNotice')).toHaveCount(0);
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
@@ -105,7 +108,7 @@ test('API failure is explicit and does not silently substitute demo data', async
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 expect(page.locator('.stopsList[aria-label="Остановки"]')).not.toContainText('на примере');
await page.goto('/?demo=1');
await expect(page.locator('.demoBanner')).toBeVisible();
});
@@ -117,7 +120,7 @@ test('a stale timetable loses expired departures across the date boundary', asyn
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();
await expect(page.getByText('Ближайшие отправления закончились.', { exact: false })).toBeVisible();
});
test('full timetable keeps past trips, switches date and direction, and restores its URL', async ({ page }, info) => {
@@ -156,7 +159,8 @@ test('saved full timetable works offline and does not substitute another date',
});
await context.setOffline(true);
await page.reload();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
await expect(page.locator('.offlineBanner')).toBeVisible();
await expect(page.locator('.savedNotice')).toHaveCount(0);
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();
@@ -177,7 +181,7 @@ test('hours and minutes are consistent in departures and favorites, which expire
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 page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' }).getByRole('link', { name: /Избранное/ }).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);
@@ -185,7 +189,7 @@ test('hours and minutes are consistent in departures and favorites, which expire
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('данные могут быть устаревшими');
await expect(page.locator('.favoriteStatus')).toContainText('Расписание давно не обновлялось');
});
test('about page is accessible from the footer and fits a narrow screen', async ({ page }, info) => {

118
e2e/discovery.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
test('first home visit teaches on the real page and dismissing is remembered', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1&city=1&stop=101');
const guide = page.locator('.featureDiscovery');
await expect(guide).toBeVisible();
await expect(guide.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
await expect(page.locator('[data-discovery="stop-picker"]')).toBeVisible();
await expect(page.locator('#departures')).toBeVisible();
await guide.getByRole('button', { name: 'Пропустить', exact: true }).click();
await expect(guide).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done');
await page.reload();
await expect(page.locator('#departures')).toBeVisible();
await expect(guide).toHaveCount(0);
expect(errors).toEqual([]);
});
test('all guide steps fit a narrow dark viewport, keep the target clear, and work from the keyboard', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 568 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addInitScript(() => localStorage.setItem('ridebus:theme', 'dark'));
await page.goto('/?demo=1&city=1&stop=101');
const guide = page.locator('.featureDiscovery');
const card = guide.locator('.discoveryCard');
const targets = ['stop-picker', 'departures', 'favorites', 'quick-trips', 'settings'];
for (const [index, target] of targets.entries()) {
await expect(card).toHaveAttribute('data-step', target);
await expect.poll(async () => page.evaluate(() => {
const hint = document.querySelector('.discoveryCard')!.getBoundingClientRect();
const light = document.querySelector('.discoverySpotlight')!.getBoundingClientRect();
const overlaps = hint.left < light.right && hint.right > light.left && hint.top < light.bottom && hint.bottom > light.top;
return { fits: hint.left >= 0 && hint.top >= 0 && hint.right <= innerWidth && hint.bottom <= innerHeight, overlaps };
})).toEqual({ fits: true, overlaps: false });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(card).toHaveCSS('animation-name', 'none');
await expect(card.getByRole('button', { name: index === 4 ? 'Понятно' : 'Далее', exact: true })).toBeInViewport({ ratio: 1 });
await expect(card.getByRole('button', { name: 'Пропустить подсказки', exact: true })).toHaveCSS('min-width', '44px');
if (index === 0) {
await expect.poll(async () => card.evaluate(element => {
const bounds = element.getBoundingClientRect();
const copy = element.querySelector<HTMLElement>('.discoveryCopy')!;
const copyBounds = copy.getBoundingClientRect();
const description = copy.querySelector('p')!.getBoundingClientRect();
const actions = element.querySelector('.discoveryActions')!.getBoundingClientRect();
return copy.scrollHeight <= copy.clientHeight + 1 && description.bottom <= copyBounds.bottom + 1 && copyBounds.top >= bounds.top && copyBounds.bottom <= actions.top;
})).toBe(true);
await page.setViewportSize({ width: 320, height: 360 });
await expect(card.getByRole('button', { name: 'Далее', exact: true })).toBeInViewport({ ratio: 1 });
const body = card.locator('.discoveryCopy');
await expect.poll(async () => card.evaluate(element => {
const copy = element.querySelector<HTMLElement>('.discoveryCopy')!;
const overflowing = copy.scrollHeight > copy.clientHeight + 1;
return overflowing === Boolean(element.querySelector('.discoveryScrollHint'));
})).toBe(true);
expect(await body.evaluate(element => element.getBoundingClientRect().bottom <= element.closest('.discoveryCard')!.querySelector('.discoveryActions')!.getBoundingClientRect().top)).toBe(true);
const dimensions = await body.evaluate(element => ({ scroll: element.scrollHeight, visible: element.clientHeight }));
if (dimensions.scroll > dimensions.visible + 1) {
await expect(card.locator('.discoveryScrollHint')).toBeVisible();
await expect(body).toHaveAttribute('role', 'region');
await body.evaluate(element => { element.scrollTop = element.scrollHeight; });
expect(await body.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
} else {
await expect(card.locator('.discoveryScrollHint')).toHaveCount(0);
expect(await body.evaluate(element => element.querySelector('p')!.getBoundingClientRect().bottom <= element.getBoundingClientRect().bottom + 1)).toBe(true);
}
await page.setViewportSize({ width: 320, height: 568 });
await expect(card.locator('.discoveryScrollHint')).toHaveCount(0);
}
if (index === 2) {
await page.keyboard.press('ArrowLeft');
await expect(card).toHaveAttribute('data-step', 'departures');
await page.keyboard.press('ArrowRight');
await expect(card).toHaveAttribute('data-step', target);
}
if (index === 0 || index === 4) await page.screenshot({ path: `test-results/discovery-${target}-${info.project.name}.png`, animations: 'disabled' });
await page.keyboard.press('ArrowRight');
}
await expect(guide).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done');
});
test('guide can be replayed from settings and Escape dismisses it', async ({ page }, info) => {
await page.addInitScript(() => localStorage.setItem('ridebus:discovery:v1', 'done'));
await page.goto('/settings?demo=1');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
await page.keyboard.press('Escape');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' });
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
});
test('blocked storage does not trap visitors in the guide or prevent a manual replay', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => {
Storage.prototype.getItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
});
await page.goto('/?demo=1');
await expect(page.locator('.featureDiscovery')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' });
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await nav.getByRole('link', { name: 'Главная', exact: true }).click();
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
expect(errors).toEqual([]);
});

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
const time = Date.parse('2026-10-07T09:00:00.000Z');
const routes = [{ id: 1, number: '112С', name: 'Вокзал → Центр' }, { id: 2, number: '10', name: 'Вокзал → Больница' }];
@@ -48,7 +48,8 @@ test('saved journeys retain route/direction, allow rename/delete, and remain ava
await readyForOffline(page);
await context.setOffline(true); await page.reload();
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
await expect(page.locator('.tripSaved')).toContainText('могут быть устаревшими');
await expect(page.locator('.tripSaved')).toHaveCount(0);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await page.goto('/favorites?demo=1');
await expect(page.locator('.quickTripCard')).toHaveCount(1);
await context.setOffline(false);
@@ -82,7 +83,8 @@ test('hour grid jumps to next departure and opens a cached trip with stop-by-sto
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 expect(page.locator('.routeTimelineCard .savedNotice')).toHaveCount(0);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await page.locator('.journeyTimeline a').nth(1).click();
await expect(page).toHaveURL(/stop=102/);
await expect(page).toHaveURL(/date=2026-10-07/);
@@ -117,10 +119,10 @@ test('nearby search requests location only on click, groups stops, and never per
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 page.goto('/stops?city=1');
await expect(page.locator('.stopItem')).toHaveCount(2);
expect(searches).toBe(0);
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
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();
@@ -136,6 +138,7 @@ test('nearby search requests location only on click, groups stops, and never per
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('button', { name: 'Все остановки', exact: true }).click();
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('.nearbyResults')).toHaveCount(0);
});
@@ -145,6 +148,7 @@ test('location denial leaves manual search usable', async ({ page }) => {
await page.goto('/?demo=1&city=1');
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
await expect(page.locator('.nearbyMessage')).toContainText('Доступ к местоположению закрыт');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Больница');
await page.locator('.stopItem').click();
await expect(page.locator('#board-title')).toHaveText('Больница');
@@ -164,7 +168,7 @@ test('targeted cancellation preserves details, filters by route, and expires off
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 expect(page.locator('.alertSaved')).toHaveCount(0);
await page.clock.fastForward(3600001);
await expect(page.locator('.serviceAlert')).toHaveCount(0);
await expect(page.locator('.alertsUnavailable')).toBeVisible();
@@ -189,7 +193,7 @@ test('storage upgrade preserves existing favorites while adding journeys', async
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 page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.quickTripCard')).toHaveCount(1);
});

14
e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test as base } from '@playwright/test';
// Feature discovery has separate first-visit tests. Other tests use a returning
// visitor so a first-run overlay does not hide the interaction being verified.
export const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
try { localStorage.setItem('ridebus:discovery:v1', 'done'); } catch {}
});
await use(page);
},
});
export { expect } from '@playwright/test';
export type { Page } from '@playwright/test';

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
async function seedTrips(page: Page, count: number) {
await page.evaluate(async count => {
@@ -97,7 +97,7 @@ test('opening a saved journey scrolls to the correct board across cities and fro
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.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top < innerHeight - 100)).toBe(true);
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();
@@ -182,19 +182,22 @@ test('blocked native sharing and clipboard offer a selectable manual link', asyn
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('Больница');
await page.goto('/stops?demo=1&city=1&stop=101');
const all = page.locator('.stopsList[aria-label="Остановки"]');
await expect(all.locator('.stopItem')).toHaveCount(5);
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));
const normalStyles = await all.evaluate(styles);
await page.getByRole('button', { name: 'Рядом', exact: true }).click();
const nearby = page.getByLabel('Остановки рядом', { exact: true });
await expect(nearby.locator('.stopItem')).toHaveCount(3);
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
expect(await nearby.evaluate(styles)).toEqual(normalStyles);
await nearby.getByRole('button', { name: /Больница/ }).click();
await expect(nearby.locator('.activeStop')).toContainText('Больница');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
await expect(all.locator('.activeStop')).toHaveText('Больница');
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
});

98
e2e/navigation.spec.ts Normal file
View File

@@ -0,0 +1,98 @@
import { test, expect, type Page } from './fixtures';
async function offlineReady(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('four sections fit phones and tablets and the home picker stays compact', async ({ page }) => {
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await expect(page.locator('.compactPicker .stopItem')).toHaveCount(0);
for (const width of [320, 390, 760, 761, 820, 1050, 1120, 1440]) {
await page.setViewportSize({ width, height: 900 });
const nav = page.getByRole('navigation', { name: width <= 760 ? 'Навигация на телефоне' : 'Основная навигация' });
await expect(nav.getByRole('link')).toHaveCount(4);
for (const name of ['Главная', 'Остановки', 'Избранное', 'Настройки']) await expect(nav.getByRole('link', { name: new RegExp(`^${name}`) })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
expect((await page.locator('.compactPicker').boundingBox())!.height).toBeLessThan(300);
}
await page.setViewportSize({ width: 390, height: 844 });
const nav = page.getByRole('navigation', { name: 'Навигация на телефоне' });
for (const [name, path] of [['Остановки', '/stops'], ['Избранное', '/favorites'], ['Настройки', '/settings'], ['Главная', '/']]) {
await nav.getByRole('link', { name: new RegExp(`^${name}`) }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe(path);
await expect(nav.getByRole('link', { name: new RegExp(`^${name}`) })).toHaveAttribute('aria-current', 'page');
}
});
test('choosing a stop from the home dialog closes it and scrolls to departures on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
const original = HTMLElement.prototype.scrollIntoView;
(window as any).stopScrolls = [];
HTMLElement.prototype.scrollIntoView = function(options) {
if (this.id === 'departures') (window as any).stopScrolls.push(options);
original.call(this, options);
};
});
await page.goto('/?demo=1&city=1');
const trigger = page.getByRole('button', { name: 'Выбрать остановку', exact: true });
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Выбрать остановку', exact: true });
await expect(dialog).toBeVisible();
// Opening the chooser does not focus search or summon the phone keyboard.
await expect(dialog.getByRole('searchbox')).not.toBeFocused();
await dialog.getByRole('searchbox').fill('Площадь');
await dialog.locator('.stopItem').click();
await expect(dialog).toHaveCount(0);
await expect(page.locator('#board-title')).toHaveText('Площадь Свободы');
await expect(page.locator('#departures')).toBeFocused();
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('smooth');
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);
await expect(trigger).toContainText('Площадь Свободы');
await page.emulateMedia({ reducedMotion: 'reduce' });
await trigger.click();
await dialog.getByRole('searchbox').fill('Площадь');
await dialog.locator('.stopItem').click();
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.length)).toBe(2);
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('auto');
});
test('catalog selection scrolls on a phone while retaining the current date', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/stops?demo=1&city=1&view=day&date=2026-10-08');
await page.getByRole('searchbox').fill('Площадь');
await page.locator('.stopItem').click();
await expect(page.locator('#departures')).toBeFocused();
await expect(page).toHaveURL(/\/stops\?.*stop=101.*date=2026-10-08/);
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('disconnecting now shows a single status and older departures receive a warning later', async ({ page, context }) => {
await page.clock.install({ time: new Date('2026-10-07T09:00:00Z') });
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.getByRole('button', { 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 offlineReady(page);
await context.setOffline(true);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await expect(page.locator('.savedNotice, .tripSaved, .alertSaved')).toHaveCount(0);
await expect(page.locator('main')).not.toContainText('устарев');
await page.reload();
await expect(page.locator('.departureRow')).toHaveCount(8);
await expect(page.locator('.savedNotice, .tripSaved, .alertSaved')).toHaveCount(0);
await page.clock.fastForward('00:31:00');
await expect(page.locator('.savedNotice')).toContainText('Расписание давно не обновлялось');
await expect(page.locator('.tripSaved')).toContainText('Расписание давно не обновлялось');
await page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteStatus')).toContainText('Расписание давно не обновлялось');
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('theme settings are reachable from navigation and preserve timetable and favorites', async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'light' });
@@ -30,7 +30,7 @@ test('theme settings are reachable from navigation and preserve timetable and fa
await expect(page.locator('.boardCard')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await expect(page.locator('.citySelect')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await page.screenshot({ path: `test-results/schedule-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.favoriteRoute').first()).toHaveCSS('background-color', 'rgb(28, 35, 48)');
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
qa/settings-dark-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
qa/stops-live-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -2,7 +2,7 @@ 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, SettingsIcon, WifiOff, X } from './components/Icons';
import { ArrowRight, ArrowUpRight, Bookmark, BusFront, ChevronDown, Home, MapPin, Search, SettingsIcon, WifiOff, X } from './components/Icons';
import { DataError, loadResource } from './lib/api';
import { db, readPreference, savePreference, stopKey } from './lib/storage';
import { Departures } from './components/Departures';
@@ -14,6 +14,8 @@ import { NearbyStops } from './components/NearbyStops';
import { RoutePage } from './components/RoutePage';
import { Settings } from './components/Settings';
import { useAppTheme } from './lib/theme';
import { Dialog } from './components/Dialog';
import { FeatureDiscovery } from './components/FeatureDiscovery';
import { StopList, StopListItem } from './components/StopList';
import { positiveParam } from './lib/links';
import { isTravelDate, localDate } from './lib/time';
@@ -42,7 +44,9 @@ export default function App() {
const aboutPage = location.pathname === '/about';
const routePage = location.pathname === '/route';
const settingsPage = location.pathname === '/settings';
const schedulePage = !favoritesPage && !aboutPage && !routePage && !settingsPage;
const stopsPage = location.pathname === '/stops';
const homePage = location.pathname === '/';
const schedulePage = homePage || stopsPage;
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());
@@ -51,6 +55,10 @@ export default function App() {
const [search, setSearch] = useState('');
const [notice, setNotice] = useState('');
const [nearbySelected, setNearbySelected] = useState<Stop | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [nearbyOnly, setNearbyOnly] = useState(false);
const [discoveryRestart, setDiscoveryRestart] = useState(0);
const [selectionScroll, setSelectionScroll] = useState<{ cityId: number; stopId: number; routeId?: number } | null>(null);
const boardRef = useRef<HTMLElement>(null);
const scrolledLocation = useRef<string | null>(null);
const favorites = useLiveQuery(() => db.favorites.where('mode').equals(mode).toArray(), [mode], []);
@@ -91,27 +99,31 @@ export default function App() {
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(() => { window.scrollTo(0, 0); setPickerOpen(false); }, [location.pathname]);
useEffect(() => { setNearbySelected(null); }, [mode, cityId]);
const boardTarget = location.state?.boardTarget as { cityId: number; stopId: number; routeId: number } | undefined;
const boardTarget = selectionScroll ?? 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;
|| cityId !== boardTarget.cityId || stop?.id !== boardTarget.stopId
|| (boardTarget.routeId !== undefined && (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' });
setSelectionScroll(null);
}, [schedulePage, boardTarget, location.key, stops.isPending, boardPending, cityId, stop?.id, routeId, view]);
function selectCity(value: string) {
setCityChoice(value); setStopChoice(''); setSearch('');
setCityChoice(value); setStopChoice(''); setSearch(''); setSelectionScroll(null);
savePreference(`${mode}:city`, value); savePreference(`${mode}:stop`, '');
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: value, ...(view === 'day' ? { view, date } : {}) });
}
function selectStop(value: Stop) {
function selectStop(value: Stop, fromList = false) {
setStopChoice(String(value.id));
setPickerOpen(false);
if (fromList && cityId && matchMedia('(max-width: 760px)').matches) setSelectionScroll({ cityId, stopId: 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 } : {}) });
}
@@ -129,7 +141,7 @@ export default function App() {
}
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]);
selectStop(selected ?? group.stops.find(item => item.routesCount > 0) ?? group.stops[0], true);
}
async function toggleFavorite() {
if (!city || !stop) return;
@@ -145,49 +157,66 @@ export default function App() {
setSearch(''); navigate(`/${demo ? '?demo=1' : ''}`);
}
function openPicker(nearby = false) { setNearbyOnly(nearby); setSearch(''); setPickerOpen(true); }
function restartDiscovery() { setDiscoveryRestart(value => value + 1); navigate(`/${location.search}`); }
function cityPicker(id: string) {
return <><label className="visuallyHidden" htmlFor={id}>Ваш город</label><div className="citySelect"><MapPin size={18} /><select id={id} 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></>;
}
function stopMessage() {
if (cities.isPending || (cityId && stops.isPending)) return <div className="listMessage" role="status">Загружаем остановки…</div>;
if (cities.isError && !city) return <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>;
if (stops.isError && !stops.data) return <div className="listMessage"><h3>Нет доступных остановок</h3><p>{online ? 'Не удалось загрузить список.' : 'Для этого города ещё нет загруженных остановок.'}</p><button className="secondaryButton" onClick={() => void stops.refetch()}>Повторить</button></div>;
if (!filtered.length) return <div className="listMessage"><Search size={25} /><h3>{search ? 'Ничего не нашлось' : 'Пока нет остановок'}</h3><p>{search ? 'Попробуйте другое название.' : 'Выберите другой город или обновите данные.'}</p></div>;
return 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)} />);
}
function stopCatalog() {
return <div className="stopCatalog">{cityPicker('catalog-city')}<div className="catalogModes" role="group" aria-label="Поиск остановок"><button aria-pressed={!nearbyOnly} onClick={() => setNearbyOnly(false)}>Все остановки</button><button aria-pressed={nearbyOnly} disabled={!online || !cityId} onClick={() => setNearbyOnly(true)}><MapPin size={17} />Рядом</button></div>
{nearbyOnly && cityId ? <NearbyStops key={`${mode}:${cityId}`} cityId={cityId} mode={mode} online={online} autoLocate selectedGroupKey={selectedGroup?.key} favoriteStopIds={favorites.filter(item => item.cityId === cityId).map(item => item.id)} onSelect={value => { setNearbySelected(value); selectStop(value, true); }} /> : <><label className="visuallyHidden" 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}>{stopMessage()}</StopList><div className="stopsFooter">{countLabel(filtered.length, ['остановка', 'остановки', 'остановок'])}</div></>}
</div>;
}
function navigation(mobile: boolean) {
return <nav className={mobile ? 'mobileNav' : 'desktopNav'} aria-label={mobile ? 'Навигация на телефоне' : 'Основная навигация'}>
<NavLink end to={`/${location.search}`} aria-label="Главная" title="Главная"><Home size={mobile ? 28 : 18} /><span className="navText">Главная</span></NavLink>
<NavLink to={`/stops${location.search}`} aria-label="Остановки" title="Остановки"><BusFront size={mobile ? 28 : 18} /><span className="navText">Остановки</span></NavLink>
<NavLink to={`/favorites${urlSuffix}`} title="Избранное" data-discovery="favorites" aria-label={`Избранное${savedCount ? `, сохранено: ${savedCount}` : ''}`}><span className={mobile ? 'navIcon' : 'desktopNavIcon'}><Bookmark size={mobile ? 28 : 18} />{mobile && savedCount > 0 && <span className="mobileBadge" key={savedCount} aria-hidden="true">{savedCount > 99 ? '99+' : savedCount}</span>}</span><span className="navText">Избранное</span>{!mobile && savedCount > 0 && <span className="navCount" key={savedCount}>{savedCount}</span>}</NavLink>
<NavLink to={`/settings${location.search}`} aria-label="Настройки" title="Настройки" data-discovery="settings"><SettingsIcon size={mobile ? 28 : 18} /><span className="navText">Настройки</span></NavLink>
</nav>;
}
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><NavLink className="settingsNavLink" to={`/settings${location.search}`} aria-label="Настройки" title="Настройки"><SettingsIcon size={18} /><span>Настройки</span></NavLink></nav>
{navigation(false)}
<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>}
{settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} /> : routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} /><span>Нет подключения · загруженное расписание доступно</span></div>}
{settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} onDiscovery={restartDiscovery} /> : 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>
{homePage ? <section className="homeHero"><h1>Расписание<br /><span>в вашем кармане.</span></h1><p>Найдите свою остановку и отправление.</p></section> : <section className="catalogHero"><h1>Остановки</h1><p>Поиск по названию или рядом с вами.</p></section>}
{homePage && trips.length > 0 && <div data-discovery="quick-trips"><QuickTrips trips={trips} online={online} onNotice={setNotice} /></div>}
<div className={`workspace ${homePage ? 'homeWorkspace' : 'stopsWorkspace'}`}>
{stopsPage ? <section className="stopsCard catalogCard" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Найти остановку</h2><MapPin size={22} /></div>{stopCatalog()}</section>
: <section className="stopsCard compactPicker" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2></div>{cityPicker('home-city')}
<button className="stopPickerButton" data-discovery="stop-picker" aria-label="Выбрать остановку" onClick={() => openPicker()}><Search size={20} /><span>{selectedGroup?.name ?? stop?.name ?? 'Выбрать остановку'}</span><ChevronDown size={18} /></button>
<div className="pickerShortcuts"><NavLink to={`/stops${location.search}`}>Все остановки <ArrowRight size={17} /></NavLink><button className="textButton" aria-label="Остановки рядом" disabled={!online || !cityId} onClick={() => openPicker(true)}><MapPin size={17} />Рядом</button></div>
{cities.isError && !city && <StopList label="Остановки">{stopMessage()}</StopList>}
</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>
{homePage && trips.length === 0 && <section className="emptyTrips"><Home size={25} /><div data-discovery="quick-trips"><h2>Мои поездки</h2><p>Сохраните поездку домой или на работу в карточке отправлений.</p></div></section>}
{pickerOpen && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>}
</>}
</main>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/settings${location.search}`}>Настройки <SettingsIcon size={18} /></NavLink><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><NavLink to={`/settings${location.search}`}><SettingsIcon size={28} /><span>Настройки</span></NavLink></nav>
{navigation(true)}
<FeatureDiscovery home={homePage} restartKey={discoveryRestart} />
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
</div>;
}

59
src/_departures.scss Normal file
View File

@@ -0,0 +1,59 @@
@mixin components {
.compactBoard { min-height: 0; }
.compactBoard .boardHeading { align-items: flex-start; gap: 12px; }
.compactBoard .boardHeading h2 { flex: 1; min-width: 0; margin: 5px 0 0; font-size: 28px; line-height: 1.3; }
.boardHeaderActions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.boardHeaderActions .iconButton { width: 44px; height: 44px; padding: 0; }
.compactBoard .scheduleControls > .saveTripButton { width: 44px; height: 44px; padding: 0; margin: 0; flex-shrink: 0; color: var(--blue); background: var(--lavender); }
.compactBoard .boardDirection { margin-top: 7px; }
.compactBoard .boardDirection:empty { display: none; }
.compactBoard .directionFilters { margin: 14px 0 0; }
.compactBoard .directionChips { gap: 6px; }
.compactBoard .directionChips button { padding: 8px 13px; line-height: 1.45; }
.compactBoard .scheduleControls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.compactBoard .scheduleViews { padding: 3px; gap: 2px; border-radius: 15px; }
.compactBoard .scheduleViews button { padding: 8px 14px; }
.compactBoard .scheduleDate { flex: 1; margin-top: 0; gap: 6px; flex-wrap: nowrap; min-width: min(100%, 230px); }
.compactBoard .scheduleDate label { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.compactBoard .scheduleDate input { flex: 1; width: 150px; padding: 8px 10px; }
.compactBoard .scheduleDate .textButton { min-height: 44px; flex-shrink: 0; padding: 8px; }
.compactBoard .timetableSummary { margin: 10px 0 0; }
.compactBoard .routeFilters { margin-top: 14px; padding: 0 0 12px; }
.compactBoard .routeFilters button { min-height: 44px; min-width: 44px; padding: 8px 13px; }
.compactBoard .boardFooter { gap: 12px; padding-top: 12px; margin-top: 12px; align-items: center; }
.compactBoard .boardFooter > span { line-height: 1.5; font-size: 14px; }
.compactBoard .refreshButton { min-height: 44px; flex-shrink: 0; padding: 8px; margin-right: -8px; }
.compactBoard .boardEmpty { min-height: 270px; padding: 35px 8px; }
@media (max-width: 760px) {
.compactBoard .boardHeading { gap: 8px; }
.compactBoard .boardHeading h2 { font-size: 24px; letter-spacing: -.3px; }
.compactBoard .scheduleControls { margin-top: 14px; }
.compactBoard .scheduleViews { flex: 1; min-width: 0; }
.compactBoard .scheduleViews button { flex: 1; padding: 8px 10px; }
.compactBoard .scheduleDate { flex-basis: 100%; }
.compactBoard .routeFilters { flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-inline: contain; gap: 6px; padding-bottom: 10px; }
.compactBoard .routeFilters button { flex-shrink: 0; white-space: nowrap; font-size: 15px; }
.compactBoard .departureRow { display: grid; grid-template-columns: 44px minmax(0, 1fr); align-items: center; gap: 8px 10px; padding: 14px 0; min-height: 0; }
.compactBoard .departureRow .routeNumber { grid-column: 1; grid-row: 1; align-self: center; min-width: 44px; height: 44px; margin-top: 0; font-size: 17px; }
.compactBoard .departureName { grid-column: 1 / -1; grid-row: 2; }
.compactBoard .departureName strong { font-size: 15px; line-height: 1.5; }
.compactBoard .departureTime { grid-column: 2; grid-row: 1; flex-direction: row; align-items: center; justify-content: flex-start; flex-wrap: wrap; margin-left: 0; padding-left: 0; gap: 3px 9px; }
.compactBoard .departureTime strong { font-size: 24px; }
.compactBoard .departureTime span { font-size: 14px; white-space: normal; line-height: 1.5; }
.compactBoard .departureTime .soon { padding: 2px 7px; }
.compactBoard .departureEntry > .shareDepartureButton { margin-top: 14px; }
.compactBoard .boardFooter > span { max-width: none; }
.compactBoard .boardEmpty { min-height: 230px; }
.compactBoard .hourlyHeading { align-items: flex-start; margin-top: 12px; gap: 6px; }
.compactBoard .hourlyHeading p { max-width: 200px; line-height: 1.6; }
}
@media (max-width: 360px) {
.compactBoard .boardHeading h2 { font-size: 22px; }
.compactBoard .boardHeaderActions { gap: 0; }
.compactBoard .scheduleDate { width: 100%; }
.compactBoard .departureTime strong { font-size: 23px; }
}
}

38
src/_discovery.scss Normal file
View File

@@ -0,0 +1,38 @@
@mixin components {
.featureDiscovery { position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; color: var(--ink); overflow: hidden; }
.featureDiscovery::backdrop { background: transparent; }
.discoverySpotlight { position: fixed; border: 2px solid var(--focus); border-radius: 22px; box-shadow: 0 0 0 100vmax #101e385c, 0 0 0 5px color-mix(in srgb, var(--focus) 22%, transparent); pointer-events: none; }
.discoveryCard { position: fixed; left: 16px; top: 16px; display: flex; flex-direction: column; width: min(360px, calc(100vw - 32px)); padding: 20px; border: 1px solid var(--control-border); border-radius: 26px; background: var(--surface); color: var(--ink); box-shadow: 0 12px 42px #101e3835; visibility: hidden; overflow: hidden; }
.discoveryCard.discoveryReady { visibility: visible; animation: discoveryAppear .2s ease-out both; }
.discoveryTop { flex-shrink: 0; display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.discoverySymbol { width: 38px; height: 38px; flex-shrink: 0; display: grid; place-items: center; border-radius: 13px; background: var(--green); color: var(--green-ink); }
.discoveryCounter { color: var(--muted); font-size: 13px; line-height: 1.5; font-weight: 700; }
.discoveryClose { min-width: 44px; min-height: 44px; width: 44px; height: 44px; padding: 12px; margin-left: auto; color: var(--muted); }
.discoveryCopy { min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; animation: discoveryAppear .2s ease-out both; }
.discoveryCopy:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: 5px; }
.discoveryCopy h2 { font-size: 23px; line-height: 1.3; letter-spacing: -.3px; font-weight: 800; }
.discoveryCopy p { font-size: 15px; line-height: 1.7; color: var(--muted); margin-top: 10px; }
.discoveryScrollHint { flex-shrink: 0; margin-top: 6px; font-size: 13px; line-height: 20px; color: var(--blue-muted); }
.discoveryProgress { flex-shrink: 0; display: flex; gap: 5px; margin-top: 18px; }
.discoveryProgress span { flex: 1; height: 4px; border-radius: 4px; background: var(--lavender); }
.discoveryProgress span.seen { background: var(--blue); }
.discoveryActions { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; }
.discoveryActions > button { min-height: 44px; }
.discoveryActions .primaryButton { padding: 11px 18px; }
@keyframes discoveryAppear { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 360px) {
.discoveryCard { padding: 16px; border-radius: 22px; }
.discoveryCopy h2 { font-size: 21px; }
}
@media (max-height: 480px) {
.discoveryCard { padding: 12px; }
.discoveryTop { margin-bottom: 8px; }
.discoveryCopy h2 { font-size: 20px; }
.discoveryCopy p { font-size: 14px; line-height: 1.6; }
.discoveryProgress { display: none; }
.discoveryActions { margin-top: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.discoveryCard.discoveryReady, .discoveryCopy { animation: none; }
}
}

48
src/_navigation.scss Normal file
View File

@@ -0,0 +1,48 @@
@mixin components {
.visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.desktopNavIcon { display: inline-flex; }
.homeHero, .catalogHero { padding: 34px 0 28px; }
.homeHero h1, .catalogHero h1 { font-size: clamp(30px, 3.8vw, 44px); line-height: 1.2; letter-spacing: -.4px; }
.homeHero h1 span { color: var(--blue); }
.homeHero p, .catalogHero p { margin-top: 12px; color: var(--muted); font-size: 16px; line-height: 1.7; }
.compactPicker, .catalogCard { padding: 24px 20px 18px; }
.compactPicker .sectionHeading, .catalogCard .sectionHeading { margin-bottom: 18px; }
.compactPicker .sectionHeading h2, .catalogCard .sectionHeading h2 { font-size: 22px; }
.stopPickerButton { display: flex; gap: 10px; align-items: center; width: 100%; min-height: 52px; padding: 12px 14px; margin-top: 12px; text-align: left; border: 1px solid var(--control-border); border-radius: 14px; background: var(--surface); color: var(--blue); font-size: 16px; font-weight: 700; }
.stopPickerButton > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.pickerShortcuts { display: flex; justify-content: space-between; flex-wrap: wrap; align-items: center; gap: 2px 10px; margin-top: 10px; }
.pickerShortcuts a { display: inline-flex; align-items: center; gap: 5px; font-size: 14px; font-weight: 650; min-height: 44px; }
.pickerShortcuts .textButton { padding: 8px 0; min-height: 44px; }
.catalogModes { display: flex; gap: 6px; padding: 4px; margin: 12px 0; background: var(--blue-soft); border-radius: 16px; }
.catalogModes button { flex: 1; display: flex; gap: 6px; align-items: center; justify-content: center; padding: 9px 10px; min-height: 44px; border: 0; border-radius: 12px; color: var(--blue); background: transparent; font-size: 14px; font-weight: 750; }
.catalogModes button[aria-pressed='true'] { background: var(--surface); }
.stopCatalog .nearbySection { margin-top: 0; }
.stopCatalog .nearbyTrigger { min-height: 44px; padding: 11px 14px; }
.appDialog:has(.stopCatalog) { width: min(560px, calc(100% - 24px)); }
.appDialog .stopCatalog .stopsList { max-height: min(398px, 42dvh); }
.stopCatalog .stopsFooter { padding-bottom: 0; }
.emptyTrips { display: flex; align-items: flex-start; gap: 14px; padding: 22px 24px; margin-top: 22px; border-radius: 24px; background: var(--green); color: var(--green-ink); }
.emptyTrips h2 { font-size: 20px; }
.emptyTrips p { font-size: 15px; line-height: 1.7; margin-top: 5px; }
.offlineBanner { margin-top: 16px; padding: 10px 14px; font-size: 14px; line-height: 1.6; }
.discoverySetting { margin-top: 22px; display: flex; gap: 24px; align-items: center; justify-content: space-between; }
.discoverySetting h2 { font-size: 23px; }
.discoverySetting p { margin-top: 8px; font-size: 15px; color: var(--muted); line-height: 1.7; }
.discoverySetting > button { flex-shrink: 0; min-height: 44px; }
@media (max-width: 1120px) and (min-width: 761px) {
.desktopNav .navText { display: none; }
.desktopNav a { min-width: 44px; min-height: 44px; padding: 10px 12px; }
}
@media (max-width: 760px) {
.homeHero, .catalogHero { padding: 26px 0 22px; }
.homeHero h1, .catalogHero h1 { font-size: 30px; }
.homeHero p, .catalogHero p { font-size: 15px; }
.compactPicker, .catalogCard { padding: 18px; }
.compactPicker .sectionHeading, .catalogCard .sectionHeading { margin-bottom: 14px; }
.compactPicker .sectionHeading h2, .catalogCard .sectionHeading h2 { font-size: 21px; }
.mobileNav { gap: 0; padding-left: 8px; padding-right: 8px; }
.mobileNav a { flex: 1; min-width: 0; min-height: 52px; padding: 0 2px; font-size: 13px; }
.discoverySetting { align-items: flex-start; flex-direction: column; gap: 18px; }
.emptyTrips { padding: 20px; border-radius: 22px; }
}
}

View File

@@ -11,7 +11,7 @@
.themeOption:hover { border-color: var(--control-border); }
.themeOption.selected { border-color: var(--blue); background: var(--blue-soft); }
.themeOption:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 4px; }
.themeRadio { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.themeRadio { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; margin: 0; opacity: 0; cursor: pointer; z-index: 1; }
.themeOptionTop { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 16px; }
.themeSymbol { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 15px; background: var(--surface); color: var(--blue); }
.themeIndicator { display: grid; place-items: center; width: 22px; height: 22px; border: 2px solid var(--blue-muted); border-radius: 50%; color: var(--on-accent); flex-shrink: 0; }
@@ -29,7 +29,12 @@
.settingsCard { padding: 22px 18px; border-radius: 26px; }
.themeFieldset legend { font-size: 23px; }
.themeOptions { grid-template-columns: 1fr; gap: 12px; margin-top: 20px; }
.themeOption { padding: 18px; border-radius: 18px; }
.themeOption { display: grid; grid-template-columns: 40px minmax(0, 1fr) 22px; gap: 5px 12px; padding: 18px; border-radius: 18px; }
.themeOptionTop { display: contents; }
.themeSymbol { grid-column: 1; grid-row: 1 / 3; align-self: center; width: 40px; height: 40px; border-radius: 12px; }
.themeIndicator { grid-column: 3; grid-row: 1 / 3; align-self: center; }
.themeOption > strong { grid-column: 2; grid-row: 1; font-size: 18px; }
.themeDescription { grid-column: 2; grid-row: 2; }
.mobileNav { gap: 4px; padding-left: 10px; padding-right: 10px; }
.mobileNav a { padding: 0 6px; }
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState, type Ref } from 'react';
import { NavLink } from 'react-router-dom';
import { ArrowUpRight, Bookmark, Check, Clock3, Plus, RefreshCw, WifiOff } from './Icons';
import { ArrowUpRight, Bookmark, 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 { snapshotFreshness } from '../lib/freshness';
import { QuickTripForm } from './QuickTrips';
import { HourlyTimetable } from './HourlyTimetable';
import { Alerts } from './ServiceAlerts';
@@ -24,38 +25,37 @@ export function Departures({ stop, group, onDirection, query, favorite, onFavori
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 freshness = snapshotFreshness(query.data, fullDay ? 'timetable' : 'departures', now);
const outdated = freshness.isOld;
const updateFailed = online && !query.isFetching && (!!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>
return <section ref={boardRef} id="departures" tabIndex={-1} className="boardCard compactBoard" aria-labelledby="board-title">
<div className="boardHeading" data-discovery="departures"><h2 id="board-title">{group?.name ?? stop?.name ?? 'Ваша остановка'}</h2>{stop && <div className="boardHeaderActions"><button className={`iconButton favoriteToggle ${favorite ? 'isFavorite' : ''}`} title={favorite ? 'Убрать остановку из избранного' : 'Добавить остановку в избранное'} 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>}</div>
{group && group.stops.length > 1 ? <div className="directionFilters" role="group" aria-label="Направления"><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>}
<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>{city && !!board?.routes.length && <button className="iconButton saveTripButton" data-discovery="save-trip" title="Сохранить поездку" aria-label="Сохранить поездку" onClick={() => setSaveTrip(true)}><Plus size={24} /></button>}{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>
{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 appScrollArea" role="group" 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>}
{board && (outdated || updateFailed) && <p className="savedNotice" role="status">{outdated ? 'Расписание давно не обновлялось. Проверьте изменения при подключении к сети.' : 'Не удалось обновить расписание. Попробуйте ещё раз.'}</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>
: departures.length === 0 ? <div className="boardEmpty"><span className="emptyIcon"><Clock3 size={30} /></span><h3>{fullDay ? 'На эту дату нет отправлений' : routeId ? 'Нет ближайших отправлений' : 'Пока нет отправлений'}</h3><p>{fullDay ? 'Попробуйте другую дату, маршрут или направление.' : 'Ближайшие отправления закончились. Откройте расписание на день или выберите другое направление.'}</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>
<span className="routeNumber">{departure.routeNumber}</span><div className="departureName"><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>}
{stop && <div className="boardFooter"><span title={`Время без учёта задержек${board ? `. Обновлено ${formatDate(board.updatedAt)} в ${formatTime(board.updatedAt)}` : ''}`}>По расписанию{board && ` · обновлено в ${formatTime(board.updatedAt)}`}</span><button className="refreshButton" title="Обновить расписание" disabled={query.isFetching || !online} onClick={() => void query.refetch()}><RefreshCw size={18} className={query.isFetching ? 'spinning' : ''} />{query.isFetching ? 'Обновляем' : 'Обновить'}</button></div>}
</section>;
}

View File

@@ -2,7 +2,8 @@ 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 { formatTime, formatWait, useNow } from '../lib/time';
import { snapshotFreshness } from '../lib/freshness';
import type { Board, SavedStop } from '../types';
import { Alerts } from './ServiceAlerts';
@@ -14,7 +15,8 @@ export function FavoriteCard({ stop, online, onRemove }: { stop: SavedStop; onli
staleTime: 30000, refetchInterval: online ? 60000 : false,
});
const board = query.data?.data;
const stale = !online || !!query.data?.saved || query.isError;
const freshness = snapshotFreshness(query.data, 'departures', now);
const failed = online && !query.isFetching && (!!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">
@@ -23,11 +25,11 @@ export function FavoriteCard({ stop, online, onRemove }: { stop: SavedStop; onli
<div className="favoriteDepartures" aria-label="Ближайшие отправления">
{query.isPending ? <p role="status">Загружаем отправления…</p>
: !board ? <p>{online ? 'Не удалось загрузить отправления' : 'Нет сохранённых отправлений'}</p>
: departures.length === 0 ? <p>{stale ? 'В сохранённых данных нет ближайших рейсов' : 'Ближайших отправлений пока нет'}</p>
: departures.length === 0 ? <p>Ближайших отправлений пока нет</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 && (freshness.isOld || failed) && <div className="favoriteStatus"><WifiOff size={15} /><span>{freshness.isOld ? 'Расписание давно не обновлялось' : 'Не удалось обновить отправления'}</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,179 @@
import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
import { readPreference, savePreference } from '../lib/storage';
import { ArrowRight, Bookmark, BusFront, Clock3, SettingsIcon, X } from './Icons';
const preference = 'discovery:v1';
const steps = [
{ target: 'stop-picker', title: 'Выберите остановку', text: 'Найдите остановку по названию. Полный список и поиск рядом — в разделе «Остановки».', Icon: BusFront },
{ target: 'departures', title: 'Посмотрите отправления', text: 'Смотрите ближайшие рейсы или расписание на дату. Нажмите на рейс, чтобы увидеть остановки по пути.', Icon: Clock3 },
{ target: 'favorites', title: 'Добавьте в избранное', text: 'Закладка в расписании сохраняет остановку в «Избранном» этого браузера.', Icon: Bookmark },
{ target: 'quick-trips', title: 'Сохраните свою поездку', text: 'Нажмите «+» рядом с режимом расписания, чтобы сохранить поездку с нужным маршрутом. Потом откроете её одним нажатием.', Icon: ArrowRight },
{ target: 'settings', title: 'Выберите тему', text: 'В настройках есть светлая, тёмная и системная темы. Здесь же можно повторить подсказки.', Icon: SettingsIcon },
] as const;
type Box = { left: number; top: number; width: number; height: number };
type Placement = { spotlight: Box; left: number; top: number; width: number; maxHeight: number; side: 'above' | 'below'; scrollable: boolean };
function visibleTarget(name: string) {
const targets = document.querySelectorAll<HTMLElement>(`[data-discovery="${name}"]`);
for (const target of targets) {
if (!target.getClientRects().length || !target.offsetWidth || !target.offsetHeight) continue;
if (name === 'departures') return target.querySelector<HTMLElement>('.boardHeading, .boardEmpty h3') ?? target;
if (name === 'quick-trips') return target.querySelector<HTMLElement>('.tripsTitle h2, h2') ?? target;
return target;
}
return null;
}
function isFixed(target: HTMLElement) {
for (let element: HTMLElement | null = target; element; element = element.parentElement) {
if (getComputedStyle(element).position === 'fixed') return true;
}
return false;
}
/** restartKey is incremented by Settings after navigating home to replay the guide. */
export function FeatureDiscovery({ home, restartKey = 0, onClose }: { home: boolean; restartKey?: number; onClose?: () => void }) {
const [active, setActive] = useState(false);
const [index, setIndex] = useState(0);
const [placement, setPlacement] = useState<Placement | null>(null);
const completed = useRef(readPreference(preference) === 'done');
const lastRestart = useRef(restartKey);
const dialog = useRef<HTMLDialogElement>(null);
const card = useRef<HTMLDivElement>(null);
const nextButton = useRef<HTMLButtonElement>(null);
const titleId = useId();
const textId = useId();
const step = steps[index];
const finish = useCallback(() => {
completed.current = true;
savePreference(preference, 'done');
setActive(false);
onClose?.();
}, [onClose]);
useLayoutEffect(() => {
const replay = restartKey !== lastRestart.current;
lastRestart.current = restartKey;
if (replay) completed.current = false;
if (!home) {
if (active) finish();
return;
}
if (replay || !completed.current) {
setIndex(0);
setPlacement(null);
setActive(true);
}
// Step changes must not restart the guide; only the page or explicit replay does.
}, [home, restartKey]);
useLayoutEffect(() => {
if (!active) return;
const element = dialog.current;
if (!element) return;
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
element.showModal();
nextButton.current?.focus({ preventScroll: true });
return () => {
element.close();
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });
};
}, [active]);
useLayoutEffect(() => {
if (!active) return;
const target = visibleTarget(step.target);
if (!target) {
if (index < steps.length - 1) setIndex(index + 1);
else finish();
return;
}
const motion = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
const shortViewport = (window.visualViewport?.height ?? innerHeight) < 700 && !isFixed(target);
const previousMargin = target.style.scrollMarginBlockStart;
if (shortViewport) target.style.scrollMarginBlockStart = '24px';
target.scrollIntoView({ block: shortViewport ? 'start' : 'center', inline: 'nearest', behavior: motion });
nextButton.current?.focus({ preventScroll: true });
let frame = 0;
const position = () => {
frame = 0;
if (!target.isConnected || !card.current) return;
const viewport = window.visualViewport;
const viewLeft = viewport?.offsetLeft ?? 0;
const viewTop = viewport?.offsetTop ?? 0;
const viewWidth = viewport?.width ?? innerWidth;
const viewHeight = viewport?.height ?? innerHeight;
const edge = 16;
const gap = 16;
const rect = target.getBoundingClientRect();
const spotlightLeft = Math.max(viewLeft + 8, Math.min(rect.left - 7, viewLeft + viewWidth - 16));
const spotlightTop = Math.max(viewTop + 8, Math.min(rect.top - 7, viewTop + viewHeight - 16));
const spotlight = {
left: spotlightLeft,
top: spotlightTop,
width: Math.max(8, Math.min(rect.right + 7, viewLeft + viewWidth - 8) - spotlightLeft),
height: Math.max(8, Math.min(rect.bottom + 7, viewTop + viewHeight - 8) - spotlightTop),
};
const width = Math.min(360, viewWidth - edge * 2);
const copy = card.current.querySelector<HTMLElement>('.discoveryCopy');
const hint = card.current.querySelector<HTMLElement>('.discoveryScrollHint');
const hintHeight = hint ? hint.offsetHeight + 6 : 0;
const desiredHeight = card.current.scrollHeight + 2 + Math.max(0, (copy?.scrollHeight ?? 0) - (copy?.clientHeight ?? 0)) - hintHeight;
const above = spotlight.top - viewTop - edge - gap;
const below = viewTop + viewHeight - edge - spotlight.top - spotlight.height - gap;
const side = below >= desiredHeight || below >= above ? 'below' : 'above';
const maxHeight = Math.max(64, side === 'below' ? below : above);
const scrollable = desiredHeight > maxHeight + 1;
const height = Math.min(desiredHeight + (scrollable ? hintHeight || 26 : 0), maxHeight);
const left = Math.max(viewLeft + edge, Math.min(rect.left + rect.width / 2 - width / 2, viewLeft + viewWidth - width - edge));
const candidateTop = side === 'below' ? spotlight.top + spotlight.height + gap : spotlight.top - gap - height;
const top = Math.max(viewTop + edge, Math.min(candidateTop, viewTop + viewHeight - edge - height));
const next: Placement = { spotlight, left: Math.round(left), top: Math.round(top), width, maxHeight: Math.floor(maxHeight), side, scrollable };
setPlacement(current => JSON.stringify(current) === JSON.stringify(next) ? current : next);
};
const schedule = () => { if (!frame) frame = requestAnimationFrame(position); };
position();
const observer = new ResizeObserver(schedule);
observer.observe(target);
if (card.current) observer.observe(card.current);
window.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
window.visualViewport?.addEventListener('resize', schedule);
window.visualViewport?.addEventListener('scroll', schedule);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener('scroll', schedule, true);
window.removeEventListener('resize', schedule);
window.visualViewport?.removeEventListener('resize', schedule);
window.visualViewport?.removeEventListener('scroll', schedule);
target.style.scrollMarginBlockStart = previousMargin;
};
}, [active, index, step.target, finish]);
const positioned = Boolean(placement);
useLayoutEffect(() => {
if (active && positioned) nextButton.current?.focus({ preventScroll: true });
}, [active, index, positioned]);
function next() { if (index === steps.length - 1) finish(); else setIndex(index + 1); }
if (!active) return null;
const Icon = step.Icon;
return <dialog ref={dialog} className="featureDiscovery" aria-labelledby={titleId} aria-describedby={textId} onCancel={event => { event.preventDefault(); finish(); }} onKeyDown={event => {
if (event.key === 'ArrowRight') { event.preventDefault(); next(); }
if (event.key === 'ArrowLeft' && index > 0) { event.preventDefault(); setIndex(index - 1); }
}}>
{placement && <div className="discoverySpotlight" aria-hidden="true" style={placement.spotlight} />}
<div ref={card} className={`discoveryCard ${placement ? 'discoveryReady' : ''}`} data-step={step.target} style={placement ? { left: placement.left, top: placement.top, width: placement.width, maxHeight: placement.maxHeight } : undefined}>
<div className="discoveryTop"><span className="discoverySymbol" aria-hidden="true"><Icon size={24} /></span><span className="discoveryCounter">Подсказка {index + 1} / {steps.length}</span><button className="iconButton discoveryClose" aria-label="Пропустить подсказки" onClick={finish}><X size={20} /></button></div>
<div className="discoveryCopy" key={step.target} tabIndex={placement?.scrollable ? 0 : undefined} role={placement?.scrollable ? 'region' : undefined} aria-label={placement?.scrollable ? 'Описание подсказки' : undefined}><h2 id={titleId}>{step.title}</h2><p id={textId}>{step.text}</p></div>
{placement?.scrollable && <p className="discoveryScrollHint">Описание можно прокрутить ↓</p>}
<div className="discoveryProgress" aria-hidden="true">{steps.map((item, number) => <span key={item.target} className={number <= index ? 'seen' : ''} />)}</div>
<div className="discoveryActions"><button className="textButton" onClick={index ? () => setIndex(index - 1) : finish}>{index ? 'Назад' : 'Пропустить'}</button><button ref={nextButton} className="primaryButton" onClick={next}>{index === steps.length - 1 ? 'Понятно' : 'Далее'}<ArrowRight size={18} /></button></div>
</div>
</dialog>;
}

View File

@@ -5,7 +5,7 @@ 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[] }) {
export function NearbyStops({ cityId, mode, online, onSelect, selectedGroupKey, favoriteStopIds, autoLocate = false }: { cityId: number; mode: 'live' | 'demo'; online: boolean; onSelect: (stop: Stop) => void; selectedGroupKey?: string; favoriteStopIds: number[]; autoLocate?: boolean }) {
const [results, setResults] = useState<NearbyStop[] | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
@@ -13,6 +13,11 @@ export function NearbyStops({ cityId, mode, online, onSelect, selectedGroupKey,
const request = useRef(0);
const controller = useRef<AbortController | null>(null);
useEffect(() => () => { request.current++; controller.current?.abort(); }, []);
useEffect(() => {
if (!autoLocate) return;
const timer = setTimeout(() => void searchNearby(), 0);
return () => clearTimeout(timer);
}, [autoLocate]);
async function searchNearby() {
if (!online || busy) return;
if (!navigator.geolocation || !window.isSecureContext) { setMessage('Поиск по местоположению недоступен в этом браузере. Найдите остановку по названию.'); return; }
@@ -35,6 +40,6 @@ export function NearbyStops({ cityId, mode, online, onSelect, selectedGroupKey,
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>}
{results && <div className="nearbyResults"><p>{mode === 'demo' ? 'Примеры остановок рядом' : 'В радиусе 1 км'}{approximate && ' · местоположение приблизительное'}</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

@@ -6,6 +6,7 @@ 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 { snapshotFreshness } from '../lib/freshness';
import { Alerts } from './ServiceAlerts';
import { TripCarousel } from './TripCarousel';
import type { Board, City, QuickTrip, Route, Stop } from '../types';
@@ -41,13 +42,15 @@ function TripCard({ trip, online, onNotice, onEdit }: { trip: QuickTrip; online:
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 freshness = snapshotFreshness(query.data, 'departures', now);
const failed = online && !query.isFetching && (!!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>}
{query.data && (freshness.isOld || failed) && <p className="tripSaved">{freshness.isOld ? 'Расписание давно не обновлялось' : 'Не удалось обновить отправления'}</p>}
{query.data?.data.partial && <p className="tripSaved">Доступна часть маршрутов</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>;

View File

@@ -2,6 +2,7 @@ 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 { snapshotFreshness } from '../lib/freshness';
import { formatDate, formatTime, isTravelDate, localDate, useNow } from '../lib/time';
import { ArrowRight, Clock3, RefreshCw, WifiOff } from './Icons';
import { Alerts } from './ServiceAlerts';
@@ -22,7 +23,8 @@ export function RoutePage({ online, onNotice }: { online: boolean; onNotice: (no
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;
const freshness = snapshotFreshness(query.data, 'journey', now);
const failed = online && !query.isFetching && (!!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));
@@ -37,7 +39,7 @@ export function RoutePage({ online, onNotice }: { online: boolean; onNotice: (no
: 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>}
<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>}{(freshness.isOld || failed) && <p className="savedNotice">{freshness.isOld ? 'Расписание рейса давно не обновлялось. Проверьте его при подключении к сети.' : 'Не удалось обновить расписание рейса.'}</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>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { loadResource } from '../lib/api';
import { relevantAlerts } from '../lib/alerts';
import { snapshotFreshness } from '../lib/freshness';
import { formatDate, formatTime } from '../lib/time';
import { Warning, WifiOff } from './Icons';
import type { Route, ServiceAlert } from '../types';
@@ -16,11 +17,16 @@ export function Alerts({ mode, cityId, online, routes, compact = false }: { mode
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); };
update();
const ageTimer = window.setInterval(update, 15000);
document.addEventListener('visibilitychange', update); window.addEventListener('focus', update);
return () => { clearTimeout(timer); clearInterval(ageTimer); 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>;
const freshness = snapshotFreshness(query.data, 'alerts', now);
const failed = online && !query.isFetching && (!!query.data?.saved || query.isError);
const unavailable = !query.data && !query.isPending;
const status = failed ? 'Не удалось проверить изменения движения' : freshness.isOld ? 'Сообщения об изменениях давно не проверялись' : 'Нет загруженных сообщений об изменениях';
if (!alerts.length) return failed || (!compact && (unavailable || freshness.isOld)) ? <p className="alertsUnavailable"><WifiOff size={15} />{status}</p> : null;
return <aside className={`serviceAlerts ${compact ? 'compactAlerts' : ''}`} aria-label="Изменения движения"><div className="alertsHeading"><Warning size={19} /><strong>Изменения движения</strong></div>{(freshness.isOld || failed) && <p className="alertSaved">{status}</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

@@ -8,8 +8,8 @@ const options = [
{ value: 'dark', title: 'Тёмная', description: 'Мягкие тёмные оттенки для вечера.', Icon: Moon },
] as const;
export function Settings({ choice, resolved, saved, onChange, scheduleLink }: {
choice: ThemeChoice; resolved: 'light' | 'dark'; saved: boolean; onChange: (choice: ThemeChoice) => void; scheduleLink: string;
export function Settings({ choice, resolved, saved, onChange, scheduleLink, onDiscovery }: {
choice: ThemeChoice; resolved: 'light' | 'dark'; saved: boolean; onChange: (choice: ThemeChoice) => void; scheduleLink: string; onDiscovery: () => void;
}) {
return <section className="settingsPage" aria-labelledby="settings-title">
<NavLink className="backToSchedule" to={scheduleLink}><ArrowRight size={18} />К расписанию</NavLink>
@@ -24,5 +24,6 @@ export function Settings({ choice, resolved, saved, onChange, scheduleLink }: {
</fieldset>
<p className="themeStatus" role="status">{!saved ? 'Тема изменена, но браузер не разрешил сохранить выбор. Он будет действовать, пока эта вкладка открыта.' : choice === 'system' ? `Сейчас используется ${resolved === 'dark' ? 'тёмная' : 'светлая'} тема устройства.` : 'Выбор сохранён в этом браузере.'}</p>
</div>
<div className="settingsCard discoverySetting"><div><h2>Знакомство с RideBus</h2><p>Покажем, где выбирать остановку, смотреть отправления и сохранять поездки.</p></div><button className="secondaryButton" onClick={onDiscovery}>Показать подсказки снова <ArrowRight size={18} /></button></div>
</section>;
}

30
src/lib/freshness.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Snapshot } from '../types';
export type FreshnessResource = 'departures' | 'timetable' | 'journey' | 'alerts';
// Upcoming departures are a short moving window; full timetables and journeys
// describe a fixed date. Losing connectivity alone does not age either one.
const maxAge: Record<FreshnessResource, number> = {
departures: 30 * 60_000,
timetable: 24 * 60 * 60_000,
journey: 24 * 60 * 60_000,
alerts: 15 * 60_000,
};
export function snapshotFreshness(snapshot: Pick<Snapshot<unknown>, 'data' | 'savedAt'> | undefined, resource: FreshnessResource, now = Date.now()) {
if (!snapshot) return { checkedAt: null, ageMs: null, isOld: false };
const source = snapshot.data && typeof snapshot.data === 'object' && 'updatedAt' in snapshot.data ? snapshot.data.updatedAt : undefined;
const timestamps = [snapshot.savedAt, source]
.filter((value): value is string => typeof value === 'string')
.map(Date.parse)
.filter(Number.isFinite);
// A new browser save must not make an older server response appear fresh.
// Clamp future timestamps to tolerate device/server clock differences.
const checkedAt = timestamps.length ? Math.min(now, ...timestamps) : null;
const ageMs = checkedAt === null ? null : Math.max(0, now - checkedAt);
return {
checkedAt: checkedAt === null ? null : new Date(checkedAt).toISOString(),
ageMs,
isOld: ageMs === null || ageMs >= maxAge[resource],
};
}

View File

@@ -2,6 +2,9 @@
@use './motion';
@use './features';
@use './settings';
@use './departures';
@use './navigation';
@use './discovery';
* { 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; }
@@ -259,4 +262,7 @@ svg, .vkuiIcon { flex-shrink: 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 settings.components;
@include departures.components;
@include navigation.components;
@include discovery.components;
@include motion.components;

40
test/freshness.test.mjs Normal file
View File

@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { snapshotFreshness } from '../src/lib/freshness.ts';
const now = Date.parse('2026-10-07T17:51:00.000Z');
const ago = minutes => new Date(now - minutes * 60_000).toISOString();
const snapshot = (minutes, updatedMinutes = minutes) => ({ data: { updatedAt: ago(updatedMinutes) }, savedAt: ago(minutes), saved: true });
test('a just saved response remains fresh when used from storage', () => {
assert.deepEqual(snapshotFreshness(snapshot(1), 'departures', now), {
checkedAt: ago(1), ageMs: 60_000, isOld: false,
});
assert.equal(snapshotFreshness({ ...snapshot(1), saved: false }, 'departures', now).isOld, false);
});
test('age warnings follow resource needs rather than network connectivity', () => {
assert.equal(snapshotFreshness(snapshot(29.9), 'departures', now).isOld, false);
assert.equal(snapshotFreshness(snapshot(30), 'departures', now).isOld, true);
for (const resource of ['timetable', 'journey']) {
assert.equal(snapshotFreshness(snapshot(1439), resource, now).isOld, false);
assert.equal(snapshotFreshness(snapshot(1440), resource, now).isOld, true);
}
assert.equal(snapshotFreshness(snapshot(14), 'alerts', now).isOld, false);
assert.equal(snapshotFreshness(snapshot(15), 'alerts', now).isOld, true);
});
test('resaving an old server response cannot reset its freshness', () => {
assert.equal(snapshotFreshness(snapshot(0, 45), 'departures', now).isOld, true);
assert.equal(snapshotFreshness(snapshot(45, 0), 'departures', now).isOld, true);
assert.equal(snapshotFreshness({ data: [], savedAt: ago(16) }, 'alerts', now).isOld, true);
});
test('unavailable, legacy and clock-skewed timestamps are handled conservatively', () => {
assert.deepEqual(snapshotFreshness(undefined, 'departures', now), { checkedAt: null, ageMs: null, isOld: false });
assert.equal(snapshotFreshness({ data: { updatedAt: 'invalid' }, savedAt: ago(1) }, 'departures', now).isOld, false);
assert.equal(snapshotFreshness({ data: { updatedAt: ago(45) }, savedAt: 'invalid' }, 'departures', now).isOld, true);
assert.deepEqual(snapshotFreshness({ data: {}, savedAt: 'invalid' }, 'departures', now), { checkedAt: null, ageMs: null, isOld: true });
assert.equal(snapshotFreshness(snapshot(-5), 'departures', now).ageMs, 0);
assert.equal(snapshotFreshness(snapshot(-5, 45), 'departures', now).isOld, true);
});