diff --git a/Dockerfile b/Dockerfile
index 6fb5c45..ed3a825 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,7 @@ COPY package*.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY --from=build /app/dist ./dist
COPY server ./server
-COPY --from=build /app/src/lib/timetable.mjs /app/src/lib/weather.mjs ./src/lib/
+COPY --from=build /app/src/lib/timetable.mjs /app/src/lib/weather.mjs /app/src/lib/cancellations.mjs ./src/lib/
RUN node --input-type=module -e "await import('./server/index.mjs')"
USER node
EXPOSE 5180
diff --git a/README.md b/README.md
index e2ac172..5ac2ad4 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,8 @@ npm start
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
-- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
+- Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus».
+- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки.
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
diff --git a/artifacts/departure-features-1079x591.jpg b/artifacts/departure-features-1079x591.jpg
new file mode 100644
index 0000000..03cc948
Binary files /dev/null and b/artifacts/departure-features-1079x591.jpg differ
diff --git a/artifacts/departure-features-855x723.jpg b/artifacts/departure-features-855x723.jpg
new file mode 100644
index 0000000..2cf728e
Binary files /dev/null and b/artifacts/departure-features-855x723.jpg differ
diff --git a/artifacts/departure-features-all-cancelled.jpg b/artifacts/departure-features-all-cancelled.jpg
new file mode 100644
index 0000000..f5aa90c
Binary files /dev/null and b/artifacts/departure-features-all-cancelled.jpg differ
diff --git a/artifacts/departure-features-approaching-far.jpg b/artifacts/departure-features-approaching-far.jpg
new file mode 100644
index 0000000..9f22685
Binary files /dev/null and b/artifacts/departure-features-approaching-far.jpg differ
diff --git a/artifacts/departure-features-cancellation.jpg b/artifacts/departure-features-cancellation.jpg
new file mode 100644
index 0000000..005ddbf
Binary files /dev/null and b/artifacts/departure-features-cancellation.jpg differ
diff --git a/artifacts/departure-features-live.jpg b/artifacts/departure-features-live.jpg
new file mode 100644
index 0000000..ae603c4
Binary files /dev/null and b/artifacts/departure-features-live.jpg differ
diff --git a/artifacts/departure-features-long-wait-light.jpg b/artifacts/departure-features-long-wait-light.jpg
new file mode 100644
index 0000000..2c4f944
Binary files /dev/null and b/artifacts/departure-features-long-wait-light.jpg differ
diff --git a/artifacts/departure-features-overnight-390x667.jpg b/artifacts/departure-features-overnight-390x667.jpg
new file mode 100644
index 0000000..90559c6
Binary files /dev/null and b/artifacts/departure-features-overnight-390x667.jpg differ
diff --git a/artifacts/departure-features-overnight-879x593.jpg b/artifacts/departure-features-overnight-879x593.jpg
new file mode 100644
index 0000000..f8f6be2
Binary files /dev/null and b/artifacts/departure-features-overnight-879x593.jpg differ
diff --git a/artifacts/departure-features-street-far-390x667.jpg b/artifacts/departure-features-street-far-390x667.jpg
new file mode 100644
index 0000000..2c54633
Binary files /dev/null and b/artifacts/departure-features-street-far-390x667.jpg differ
diff --git a/artifacts/info-block-settings-879x593.jpg b/artifacts/info-block-settings-879x593.jpg
new file mode 100644
index 0000000..55fb143
Binary files /dev/null and b/artifacts/info-block-settings-879x593.jpg differ
diff --git a/artifacts/info-blocks-disabled.jpg b/artifacts/info-blocks-disabled.jpg
new file mode 100644
index 0000000..7008c11
Binary files /dev/null and b/artifacts/info-blocks-disabled.jpg differ
diff --git a/artifacts/info-service-1079x591.jpg b/artifacts/info-service-1079x591.jpg
new file mode 100644
index 0000000..31e8724
Binary files /dev/null and b/artifacts/info-service-1079x591.jpg differ
diff --git a/artifacts/info-service-390x667.jpg b/artifacts/info-service-390x667.jpg
new file mode 100644
index 0000000..4f314ea
Binary files /dev/null and b/artifacts/info-service-390x667.jpg differ
diff --git a/artifacts/info-service-855x723.jpg b/artifacts/info-service-855x723.jpg
new file mode 100644
index 0000000..513bdc6
Binary files /dev/null and b/artifacts/info-service-855x723.jpg differ
diff --git a/artifacts/info-service-879x593.jpg b/artifacts/info-service-879x593.jpg
new file mode 100644
index 0000000..140f391
Binary files /dev/null and b/artifacts/info-service-879x593.jpg differ
diff --git a/artifacts/info-service-light.jpg b/artifacts/info-service-light.jpg
new file mode 100644
index 0000000..010c272
Binary files /dev/null and b/artifacts/info-service-light.jpg differ
diff --git a/artifacts/info-service-one-block.jpg b/artifacts/info-service-one-block.jpg
new file mode 100644
index 0000000..15a9e7e
Binary files /dev/null and b/artifacts/info-service-one-block.jpg differ
diff --git a/artifacts/info-service-three-cards.jpg b/artifacts/info-service-three-cards.jpg
new file mode 100644
index 0000000..e10c509
Binary files /dev/null and b/artifacts/info-service-three-cards.jpg differ
diff --git a/artifacts/info-service-two-blocks.jpg b/artifacts/info-service-two-blocks.jpg
new file mode 100644
index 0000000..5028b5f
Binary files /dev/null and b/artifacts/info-service-two-blocks.jpg differ
diff --git a/artifacts/verification-info-blocks.md b/artifacts/verification-info-blocks.md
new file mode 100644
index 0000000..ae93d47
--- /dev/null
+++ b/artifacts/verification-info-blocks.md
@@ -0,0 +1,13 @@
+# Информационные блоки — 9 октября 2026
+
+- Вместо списка маршрутов: последний рейс сегодня и первый завтра, номер и направление. Источник — полное расписание текущей остановки; короткий live-ответ не используется для определения границ движения.
+- В настройках → «Экраны» доступны три независимых переключателя. Старые настройки включают все блоки; отключения сохраняются после перезагрузки. «Сбросить цикл» возвращает блоки.
+- Проверены три, два, один и ноль блоков через интерфейс. При одном блоке нет лишних вкладок, при нуле нет обычной информационной сцены; ручной переход сохраняет главный экран. Предупреждения остаются в цикле независимо от переключателей, реклама подавляется при отменах/объездах — проверено тестами логики сцен.
+- Браузер: 1280×900 (три блока, тёмная/светлая тема), 879×593, 1079×591, 855×723, 390×667. Новый блок помещается в карточку без внутренней прокрутки. Уточнены размеры и отступы короткой вертикальной карточки.
+- На 879×593 переключатели доступны через постоянные кнопки «Ниже»/«Выше». Проверена работа кнопок курсором; физический Xiaomi Mi Box 4 не проверялся.
+- Сверка с локальным `/api/timetable?stopId=1`: 1031 прибытие в развёрнутом расписании за сегодня и завтра; последний сегодня №6, 23:55, Баграмяна; первый завтра №22, 00:02, Больница (Новка). Время суток — Europe/Minsk, прибытию после полуночи соответствует следующий календарный день.
+- `npm test`: 54 теста прошли, включая миграцию переключателей, все выключенные блоки, предупреждения, фильтр маршрута и переход через полночь/Новый год. API-тест запущен с разрешением на локальный порт после ограничения песочницы.
+- `npm run build` и `git diff --check` прошли. Финальный JS: `index-GhNzOttK.js`, CSS: `index-DfZr3qbx.css`.
+- Исходные настройки возвращены: тёмная тема, лайм, удержание главного экрана включено, все три блока включены. Временный размер браузера сброшен; автоматическая смена экранов возобновлена.
+
+Снимки: `info-service-three-cards.jpg`, `info-service-light.jpg`, `info-service-879x593.jpg`, `info-service-1079x591.jpg`, `info-service-855x723.jpg`, `info-service-390x667.jpg`, `info-service-two-blocks.jpg`, `info-service-one-block.jpg`, `info-block-settings-879x593.jpg`, `info-blocks-disabled.jpg`.
diff --git a/server/board.mjs b/server/board.mjs
index b2d3901..7082166 100644
--- a/server/board.mjs
+++ b/server/board.mjs
@@ -1,4 +1,6 @@
import { loadTimetable } from './timetable.mjs';
+import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
+import { shiftDate } from '../src/lib/timetable.mjs';
export class ApiError extends Error {
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
@@ -38,8 +40,11 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
+ const originTime = scheduledInstant(arrival.trip?.departureTime, now)?.slice(11, 16);
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
- destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note,
+ destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
+ serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? shiftDate(localDate(now), -1) : localDate(now),
+ originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
source: 'schedule' });
}
}
@@ -47,7 +52,7 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
}
-export function normalizeAlerts(raw, routes, cityId, now) {
+export function normalizeAlerts(raw, routes, cityId, now, stops = []) {
if (!Array.isArray(raw)) throw new ApiError();
const routeIds = new Set(routes.map(route => route.id));
const routeNumbers = new Set(routes.map(route => route.number));
@@ -66,10 +71,12 @@ export function normalizeAlerts(raw, routes, cityId, now) {
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
+ if (!numbers.length) numbers.push(...routes.filter(route => targets.includes(route.id)).map(route => route.number));
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
startAt: alert.start_time || null, endAt: alert.end_time || null };
+ if (value.type === 'cancellation') value.cancellations = parseCancellationNotice(value.message, value.routeNumbers, stops);
return [value.id, value];
})).values()];
}
@@ -139,7 +146,7 @@ export function createBoardClient({
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
let alerts = [], alertsAvailable = true;
- try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); }
+ try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now(), stops); }
catch { alertsAvailable = false; }
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };
diff --git a/server/timetable.mjs b/server/timetable.mjs
index f034213..1a486f9 100644
--- a/server/timetable.mjs
+++ b/server/timetable.mjs
@@ -33,7 +33,9 @@ function normalizeSchedules(raw, route, rawStops, stopId) {
return [...new Set(value.map(Number))];
};
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
+ const origin = start || [...rawStops].sort((a, b) => Number(a.sequence) - Number(b.sequence))[0];
return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60,
+ originStopId: Number(origin?.stop_id), originTime: `${time[1].padStart(2, '0')}:${time[2]}`,
destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name,
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
});
diff --git a/src/App.tsx b/src/App.tsx
index 375a31b..8b9b87a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,18 +1,21 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
-import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
+import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Close, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
-import { RouteCloud } from './RouteCloud';
+import { ServiceWindow } from './ServiceWindow';
import { AlertCard } from './AlertCard';
import { FittedDestination } from './FittedDestination';
import { useIdleControls } from './useIdleControls';
import { PassengerQr } from './PassengerQr';
import { WeatherCard, WeatherSummary } from './WeatherBlock';
-import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
+import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
+import { boardScenes, informationBlocks, serviceWindow } from './lib/information';
+import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
+import type { DisplayDeparture } from './lib/departures';
import logo from './assets/ridebus-logo.svg?raw';
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
-import type { CSSProperties } from 'react';
+import type { ComponentProps, CSSProperties } from 'react';
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
@@ -31,19 +34,29 @@ function Countdown({ departure, now, large = false }: { departure: Departure; no
{minutes > 0 ? {parts.map(part => )} : сейчас }
;
}
-function ScheduledTime({ departure, now, className = '' }: { departure: Departure; now: number; className?: string }) {
- return {localDate(Date.parse(departure.scheduledAt)) > localDate(now) && завтра } ;
+function ScheduledTime({ departure, now, className = '', showDay = true }: { departure: Departure; now: number; className?: string; showDay?: boolean }) {
+ return {showDay && localDate(Date.parse(departure.scheduledAt)) > localDate(now) && завтра } ;
+}
+function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) {
+ return
+ {departure.status === 'cancelled' ? Отменён : }
+ {departure.status === 'notice' && Есть изменения }
+
;
}
-function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
- data: BoardData | null; departures: Departure[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean;
+export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: {
+ data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
}) {
- const first = departures[0];
+ const first = firstAvailable(departures);
+ const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
+ const waiting = longWait(settings, first, now);
+ const reading = readability(settings);
const table = useRef(null);
const [tableHeight, setTableHeight] = useState(360);
const [page, setPage] = useState(0);
- const capacity = Math.max(2, Math.floor(tableHeight / 62));
- const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
+ const capacity = Math.max(1, Math.floor(tableHeight / reading.rowHeight));
+ const remaining = departures.filter(departure => departure.id !== first?.id);
+ const visible: { items: DisplayDeparture[]; start: number; count: number; pages: number; size: number } = departurePage(remaining, rowCount, capacity, page);
const rows = Math.max(1, Math.min(rowCount, visible.size));
useLayoutEffect(() => {
if (!table.current) return;
@@ -53,46 +66,50 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
}, []);
useEffect(() => {
if (paused || visible.pages < 2) return;
- const timer = setInterval(() => setPage(value => value + 1), 7000);
+ const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
return () => clearInterval(timer);
- }, [paused, visible.pages]);
+ }, [paused, visible.pages, reading.calm]);
return
-
+
БЛИЖАЙШИЙ РЕЙС По расписанию{data?.mode === 'demo' ? ' · демо' : ''}
- {first ?
+ {first ?
{first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}
-
{first.note || first.routeName}
-
-
:
{loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}
-
{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}
}
-
СЛЕДОМ ПРИБУДУТ {visible.pages > 1 ?
{visible.start + 1}–{visible.start + visible.items.length} из {visible.count} :
}
+ {!waiting &&
{first.note || first.routeName}
}
+ {next &&
Следующий №{first.routeNumber}
}
+
+ {waiting ? {localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'} {first.status === 'notice' && Есть изменения }
: }
+ : 0}>
{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}
+
{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}
}
+ {!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'} {visible.pages > 1 ?
{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count} :
}
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
- {visible.items.map((departure, index) =>
+ {visible.items.map((departure, index) =>
{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}
-
+
)}
{first && departures.length === 1 && Это последний доступный рейс
}
- {!first &&
}
+ {!departures.length &&
}
{alerts[0] && {alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}
}
;
}
-function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
- config: Config; weather: ReturnType; now: number }) {
+function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, service }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
+ config: Config; weather: ReturnType; now: number; settings: Settings; service: ComponentProps }) {
const panel = useRef(null);
const grid = useRef(null);
const [compact, setCompact] = useState(false);
+ const blocks = informationBlocks(settings);
+ const activeBlock = blocks[page % blocks.length]?.id;
useLayoutEffect(() => {
if (!grid.current) return;
- const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330); });
+ const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
observer.observe(grid.current);
return () => observer.disconnect();
- }, [alerts.length]);
+ }, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
useEffect(() => {
const element = panel.current;
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
@@ -103,13 +120,16 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
}, 3500);
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
- return
+ return 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
{alerts.length ? 'Изменения движения' : 'Информация'}
- {!alerts.length && {['Маршруты', 'Погода', 'RideBus'].map((name, index) => onPage(index)}>{name} )} }
+ {!alerts.length && blocks.length > 1 && {blocks.map((block, index) => onPage(index)}>{block.name} )} }
{alerts.length ? :
-
-
-
+ {blocks.map(block =>
+ {block.id === 'service' && }
+ {block.id === 'weather' && }
+ {block.id === 'qr' && }
+ )}
}
{!alerts.length && (data?.alertsAvailable === false || !fresh) && Предупреждения сейчас недоступны
}
;
@@ -138,25 +158,38 @@ export default function App() {
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
- const departures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
+ const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const fresh = settings.demo || isFresh(data, now, failed);
- const held = shouldHoldScene(settings, departures[0], now);
+ const reliableAlerts = fresh && data?.alertsAvailable === true;
+ // Resolve ambiguous cancellations before applying the operator's route filter.
+ const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts);
+ const statusById = new Map(allDepartures.map(item => [item.id, item]));
+ const departures = rawDepartures.map(item => statusById.get(item.id)!);
+ const first = firstAvailable(departures);
+ const service = serviceWindow(allDepartures.filter(item => item.status !== 'cancelled'), now, settings.routeFilter);
+ const reading = readability(settings);
+ const held = shouldHoldScene(settings, first, now);
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
- const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info'];
+ const hasAlerts = alerts.length > 0;
+ const scenes = useMemo(() => boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent]);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
const elapsed = Date.now() - startedAt;
const progress = Math.min(1, elapsed / (seconds * 1000));
- const sceneState = useRef({ scenes, scene, held, settingsOpen, paused });
- sceneState.current = { scenes, scene, held, settingsOpen, paused };
+ const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm });
+ sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm };
const showNotice = useCallback((text: string) => {
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
}, []);
const goTo = useCallback((next: Scene) => {
+ if (!sceneState.current.scenes.includes(next)) return;
if (sceneState.current.held && next !== 'departures') { showNotice('Главный экран удерживается: до ближайшего рейса не больше 2 минут'); return; }
const change = () => { if (sceneState.current.scene === 'info' && next !== 'info') setInfoPage(value => value + 1); setScene(next); setStartedAt(Date.now()); };
- if (document.startViewTransition && !matchMedia('(prefers-reduced-motion: reduce)').matches) document.startViewTransition(() => flushSync(change));
+ if (document.startViewTransition && !sceneState.current.calm && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ const transition = document.startViewTransition(() => flushSync(change));
+ void transition.ready.catch(() => {}); // Resizing can interrupt the animation after the scene has changed.
+ }
else change();
}, [showNotice]);
const nextScene = useCallback(() => {
@@ -180,11 +213,11 @@ export default function App() {
if (held) { setScene('departures'); setStartedAt(Date.now()); }
else setStartedAt(Date.now());
}, [held]);
- useEffect(() => { if (urgent && scene === 'ad') { setScene('info'); setStartedAt(Date.now()); } }, [urgent, scene]);
+ useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
useEffect(() => {
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
- if (!held && elapsed >= seconds * 1000) nextScene();
- }, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]);
+ if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
+ }, [now, held, paused, settingsOpen, elapsed, seconds, nextScene, scenes.length]);
useEffect(() => {
function keydown(event: KeyboardEvent) {
if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return;
@@ -202,24 +235,25 @@ export default function App() {
const timeParts = clockFormat.format(now).split(':');
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
const direction = stopDirection(data?.stop);
- return
+ return
{data?.stop.name || config.stopName}
ridebus
{dateFormat.format(now)}
{!fresh && !settings.demo &&
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
}
- {scene === 'departures' &&
}
- {scene === 'info' &&
}
+ {scene === 'departures' &&
}
+ {scene === 'info' &&
}
{scene === 'ad' &&
РЕКЛАМА · ПРЕДПРОСМОТР {settings.adTitle} {settings.adText}
}
- {scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС {departures[0] ? <>
{departures[0].destination}
> :
Нет доступных рейсов }
}
-
+ {scene !== 'departures' && БЛИЖАЙШИЙ РЕЙС {first ? <>
{first.destination}
> :
Нет доступных рейсов }
}
+
{notice && {notice}
}
- {settingsOpen && { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
+ {settingsOpen && { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
;
}
diff --git a/src/FittedDestination.tsx b/src/FittedDestination.tsx
index f358ef5..1deae6b 100644
--- a/src/FittedDestination.tsx
+++ b/src/FittedDestination.tsx
@@ -7,9 +7,17 @@ export function FittedDestination({ text, rowHeight, as: Tag = 'h3' }: { text: s
useLayoutEffect(() => {
const element = heading.current, container = element?.parentElement;
if (!element || !container) return;
+ const context = document.createElement('canvas').getContext('2d');
const fit = () => {
element.style.fontSize = '';
- const base = parseFloat(getComputedStyle(element).fontSize);
+ const headingCss = getComputedStyle(element);
+ let base = parseFloat(headingCss.fontSize);
+ if (context && element.clientWidth > 0) {
+ context.font = `${headingCss.fontStyle} ${headingCss.fontWeight} ${headingCss.fontSize} ${headingCss.fontFamily}`;
+ const longestWord = Math.max(1, ...text.split(/\s+/).map(word => context.measureText(word).width));
+ base = Math.max(Math.min(16, base), Math.min(base, base * element.clientWidth / longestWord));
+ element.style.fontSize = `${base}px`;
+ }
const detailsHeight = Array.from(container.children).filter(child => child !== element).reduce((height, child) => {
const css = getComputedStyle(child);
return height + (css.display === 'none' || css.position === 'absolute' ? 0 : child.clientHeight + parseFloat(css.marginTop) + parseFloat(css.marginBottom));
diff --git a/src/ServiceWindow.tsx b/src/ServiceWindow.tsx
new file mode 100644
index 0000000..764293c
--- /dev/null
+++ b/src/ServiceWindow.tsx
@@ -0,0 +1,22 @@
+import { NumericText } from './NumericText';
+import { RouteBadge } from './RouteBadge';
+import type { Departure } from './types';
+
+export function ServiceWindow({ lastToday, firstTomorrow, available, tomorrowAvailable, loading, now }: {
+ lastToday: Departure | null; firstTomorrow: Departure | null; available: boolean;
+ tomorrowAvailable: boolean; loading: boolean; now: number;
+}) {
+ return <>
Первый и последний рейс
+ {available ?
+ {[
+ { label: 'Последний сегодня', departure: lastToday, empty: 'Сегодня рейсов нет', ended: Boolean(lastToday && Date.parse(lastToday.scheduledAt) + 60000 <= now) },
+ { label: 'Первый завтра', departure: firstTomorrow, empty: tomorrowAvailable ? 'Завтра рейсов нет' : 'Расписание на завтра недоступно', ended: false },
+ ].map(item =>
+
{item.label}{item.ended && время прошло }
+ {item.departure ? <>
+
{item.departure.destination}
+ > :
{item.empty}
}
+
)}
+
:
{loading ? 'Загружаем полное расписание' : 'Полное расписание пока недоступно'}
}
+ >;
+}
diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx
index 719f6b0..f5bd09a 100644
--- a/src/SettingsPanel.tsx
+++ b/src/SettingsPanel.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons';
import { schemesForTheme } from './lib/presentation.mjs';
+import { informationBlocks, informationOptions } from './lib/information';
import { useSettingsNavigation } from './useSettingsNavigation';
import type { BoardData, Config, Settings, Stop } from './types';
@@ -83,12 +84,21 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
Цветовая схема Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона
{schemesForTheme(draft.theme).map(scheme => update(draft.theme === 'light' ? 'lightColorScheme' : 'colorScheme', scheme.id)} />{scheme.name} )}
У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.
+
Читаемость
+ Профиль экрана update('displayProfile', event.target.value as Settings['displayProfile'])}>Телевизор Уличное табло
+ Расстояние просмотра update('viewingDistance', event.target.value as Settings['viewingDistance'])}>Близко Средне Далеко
+
Уличный профиль — крупнее текст и спокойнее анимация. Для просмотра издалека меньше рейсов показывается одновременно; остальные сменяются страницами.
Экраны и переходы
Главный экран, сек update('mainSeconds', Number(event.target.value))} />
Информация, сек update('infoSeconds', Number(event.target.value))} />
Следующие рейсы update('rowCount', Number(event.target.value))}>{[2,3,4,5,6].map(number => {number} )}
Удерживать главный экранКогда до рейса осталось не больше 2 минут update('holdNear', event.target.checked)} />
+ Следующий рейс того же маршрутаВ том же направлении, под ближайшим отправлением update('showNextSameRoute', event.target.checked)} />
+ Режим длительного ожиданияОт 90 минут — крупное время отправления вместо отсчёта update('longWaitEnabled', event.target.checked)} />
+ Блоки информации
+ {informationOptions.map(block => {block.label} update(block.setting, event.target.checked)} /> )}
+ {!informationBlocks(draft).length && Экран информации появится только при предупреждениях о движении.
}
{navigation.editing ? 'Стрелки — изменить · OK — готово' : navigation.remote ? 'Стрелки — переход · OK — выбрать' : 'Наведите курсор и нажмите OK'}
{ if (body.current) body.current.scrollTop -= body.current.clientHeight * .7; }}> Выше { if (body.current) body.current.scrollTop += body.current.clientHeight * .7; }}> Ниже
- { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, adsEnabled: false }); }}> Сбросить цикл Сохранить
+ { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, showNextSameRoute: true, longWaitEnabled: true, infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, adsEnabled: false }); }}> Сбросить цикл Сохранить
;
}
diff --git a/src/lib/board.mjs b/src/lib/board.mjs
index 102cd35..6fa1394 100644
--- a/src/lib/board.mjs
+++ b/src/lib/board.mjs
@@ -1,3 +1,5 @@
+import { validDate } from './timetable.mjs';
+
export const FRESH_MS = 90000;
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
export const minskDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
@@ -20,5 +22,8 @@ export function isBoardData(value, cityId, stopId) {
return Boolean(value && value.cityId === cityId && value.stop?.id === stopId && value.mode === 'live' && Array.isArray(value.departures)
&& Array.isArray(value.routes) && Array.isArray(value.alerts) && typeof value.serviceDate === 'string' && Number.isFinite(Date.parse(value.updatedAt))
&& value.departures.every(item => typeof item.id === 'string' && Number.isFinite(Date.parse(item.scheduledAt)))
- && value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)));
+ && value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)
+ && (alert.cancellations === undefined || Array.isArray(alert.cancellations) && alert.cancellations.every(target =>
+ target && typeof target.routeNumber === 'string' && Number.isSafeInteger(target.stopId) && target.stopId > 0
+ && validDate(target.serviceDate) && typeof target.time === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(target.time)))));
}
diff --git a/src/lib/cancellations.mjs b/src/lib/cancellations.mjs
new file mode 100644
index 0000000..807509f
--- /dev/null
+++ b/src/lib/cancellations.mjs
@@ -0,0 +1,34 @@
+import { validDate } from './timetable.mjs';
+
+const stopName = value => String(value || '').normalize('NFKC').trim().toLocaleLowerCase('ru').replace(/ё/g, 'е').replace(/\s+/g, ' ');
+export const normalizedRouteNumber = value => String(value || '').replace(/[\s№]/g, '').toLocaleLowerCase('ru');
+
+// The existing Telegram importer publishes this exact, reviewed notice format.
+// Free-form prose and alert validity periods are not evidence of a cancelled trip.
+export function parseCancellationNotice(message, routeNumbers, stops) {
+ if (typeof message !== 'string' || message.length > 5000) return [];
+ const header = /^(\d{2})\.(\d{2})\.(\d{4}): не выполняются рейсы:\s*\n([\s\S]+)$/u.exec(message.trim());
+ if (!header) return [];
+ const serviceDate = `${header[3]}-${header[2]}-${header[1]}`;
+ if (!validDate(serviceDate)) return [];
+ const numbers = [...new Set(routeNumbers.map(normalizedRouteNumber))].filter(Boolean);
+ let number = numbers.length === 1 ? numbers[0] : null;
+ const targets = [];
+ for (const line of header[4].split('\n').map(value => value.trim()).filter(Boolean)) {
+ const route = /^Маршрут №([^\s:]+):$/u.exec(line);
+ if (route) {
+ number = normalizedRouteNumber(route[1]);
+ if (!numbers.includes(number)) return [];
+ continue;
+ }
+ const departure = /^(.+?) — ((?:[0-2]\d:[0-5]\d)(?:,\s*[0-2]\d:[0-5]\d)*)$/u.exec(line);
+ if (!departure || !number) return [];
+ const times = departure[2].split(/,\s*/);
+ if (times.some(time => Number(time.slice(0, 2)) > 23)) return [];
+ const matches = stops.filter(stop => stopName(stop.name) === stopName(departure[1]));
+ // A name shared by two platforms cannot establish the affected platform.
+ if (matches.length !== 1) continue;
+ for (const time of times) targets.push({ routeNumber: number, stopId: Number(matches[0].id), serviceDate, time });
+ }
+ return targets;
+}
diff --git a/src/lib/departures.ts b/src/lib/departures.ts
new file mode 100644
index 0000000..e479774
--- /dev/null
+++ b/src/lib/departures.ts
@@ -0,0 +1,49 @@
+import type { Alert, Departure, Settings } from '../types';
+import { localDate } from './timetable.mjs';
+import { normalizedRouteNumber } from './cancellations.mjs';
+
+export type DisplayDeparture = Departure & { status: 'scheduled' | 'notice' | 'cancelled'; alertId?: string };
+export const LONG_WAIT_MINUTES = 90;
+
+export function displayDepartures(departures: Departure[], alerts: Alert[], stopId: number, now: number, reliableAlerts: boolean): DisplayDeparture[] {
+ const cancellations = reliableAlerts ? alerts.filter(alert => alert.type === 'cancellation'
+ && (!alert.startAt || Date.parse(alert.startAt) <= now) && (!alert.endAt || Date.parse(alert.endAt) > now)) : [];
+ const cancelledTrips = new Map();
+ const dates = new Map(departures.map(departure => [departure.id, cancellations.length ? localDate(Date.parse(departure.scheduledAt)) : '']));
+ for (const alert of cancellations) {
+ for (const target of alert.cancellations || []) {
+ const matches = departures.filter(departure => normalizedRouteNumber(target.routeNumber) === normalizedRouteNumber(departure.routeNumber)
+ && (alert.routeIds.includes(departure.routeId) || alert.routeNumbers.some(value => normalizedRouteNumber(value) === normalizedRouteNumber(departure.routeNumber))
+ || !alert.routeIds.length && !alert.routeNumbers.length)
+ && (target.stopId === stopId && target.serviceDate === dates.get(departure.id) && target.time === departure.scheduledTime
+ || target.stopId === departure.originStopId && target.serviceDate === departure.serviceDate && target.time === departure.originTime));
+ if (matches.length === 1) cancelledTrips.set(matches[0].id, alert.id);
+ }
+ }
+ return departures.map(departure => {
+ const number = normalizedRouteNumber(departure.routeNumber);
+ const relevant = cancellations.filter(alert => alert.routeIds.includes(departure.routeId)
+ || alert.routeNumbers.some(value => normalizedRouteNumber(value) === number)
+ || !alert.routeIds.length && !alert.routeNumbers.length);
+ const cancelled = cancelledTrips.get(departure.id);
+ return { ...departure, status: cancelled ? 'cancelled' : relevant.length ? 'notice' : 'scheduled', alertId: cancelled || relevant[0]?.id };
+ });
+}
+
+export function firstAvailable(departures: DisplayDeparture[]) {
+ return departures.find(departure => departure.status !== 'cancelled');
+}
+export function nextSameRoute(departures: DisplayDeparture[], first: Departure | undefined) {
+ if (!first) return undefined;
+ return departures.find(departure => departure.status !== 'cancelled' && departure.routeId === first.routeId
+ && departure.destination === first.destination && Date.parse(departure.scheduledAt) > Date.parse(first.scheduledAt));
+}
+export function longWait(settings: Settings, departure: Departure | undefined, now: number) {
+ return Boolean(settings.longWaitEnabled && departure && Date.parse(departure.scheduledAt) - now >= LONG_WAIT_MINUTES * 60000);
+}
+export function readability(settings: Settings) {
+ const scale = { near: 1, medium: 1.15, far: 1.3 }[settings.viewingDistance];
+ const rowHeight = { near: 62, medium: 80, far: 104 }[settings.viewingDistance];
+ return { scale: scale * (settings.displayProfile === 'street' ? 1.12 : 1),
+ rowHeight: rowHeight + (settings.displayProfile === 'street' ? 12 : 0), calm: settings.displayProfile === 'street' };
+}
diff --git a/src/lib/information.ts b/src/lib/information.ts
new file mode 100644
index 0000000..1215ec6
--- /dev/null
+++ b/src/lib/information.ts
@@ -0,0 +1,34 @@
+import type { Departure, Scene, Settings } from '../types';
+import { localDate, shiftDate } from './timetable.mjs';
+
+export const informationOptions = [
+ { id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' },
+ { id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
+ { id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
+] as const;
+
+export function informationBlocks(settings: Settings) {
+ return informationOptions.filter(block => settings[block.setting]);
+}
+
+export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean): Scene[] {
+ return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
+ ...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
+}
+
+// Use the complete timetable, including arrivals carried over from the previous
+// service day. A short live arrivals response cannot establish the last trip.
+export function serviceWindow(departures: Departure[], now: number, routeFilter: string) {
+ const today = localDate(now);
+ const todayStart = Date.parse(`${today}T00:00:00+03:00`);
+ const tomorrowStart = Date.parse(`${shiftDate(today, 1)}T00:00:00+03:00`);
+ const tomorrowEnd = tomorrowStart + 86400000;
+ let lastToday: Departure | null = null, firstTomorrow: Departure | null = null;
+ for (const departure of departures) {
+ if (routeFilter && String(departure.routeId) !== routeFilter) continue;
+ const at = Date.parse(departure.scheduledAt);
+ if (at >= todayStart && at < tomorrowStart && (!lastToday || at > Date.parse(lastToday.scheduledAt))) lastToday = departure;
+ if (at >= tomorrowStart && at < tomorrowEnd && (!firstTomorrow || at < Date.parse(firstTomorrow.scheduledAt))) firstTomorrow = departure;
+ }
+ return { lastToday, firstTomorrow };
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 64fdd25..4401f45 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -7,7 +7,10 @@ import type { Timetable, Weather } from '../types';
const SETTINGS_KEY = 'ridebus-board-settings-v1';
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
- rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
+ rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
+ infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
+ showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
+ adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
export function readSettings(): Settings {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
@@ -16,6 +19,9 @@ export function readSettings(): Settings {
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
+ infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
+ showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
+ displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
diff --git a/src/lib/timetable.mjs b/src/lib/timetable.mjs
index e4621e0..1a33277 100644
--- a/src/lib/timetable.mjs
+++ b/src/lib/timetable.mjs
@@ -1,5 +1,6 @@
export const TIMETABLE_MAX_MS = 7 * 86400000;
-export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
+const dayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' });
+export const localDate = now => dayFormat.format(now);
export const shiftDate = (date, days) => new Date(Date.parse(`${date}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
export function validDate(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T12:00:00Z`))
@@ -16,6 +17,8 @@ export function isTimetable(value, cityId, stopId) {
&& (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip =>
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
+ && (trip.originStopId == null || Number.isSafeInteger(trip.originStopId) && trip.originStopId > 0)
+ && (trip.originTime == null || typeof trip.originTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(trip.originTime))
&& Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7)))));
}
export function timetableAvailable(bundle, now) {
@@ -42,6 +45,7 @@ export function timetableDepartures(bundle, date) {
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
+ serviceDate, originStopId: trip.originStopId, originTime: trip.originTime,
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
}
}
diff --git a/src/styles.css b/src/styles.css
index 15c8913..a5bb28f 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -86,7 +86,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.information-tabs { display: none; gap: 8px; }
.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
.information-tabs button[aria-current='page'] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
-.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
+.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--info-columns, 3), minmax(0, 1fr)); gap: 24px; }
.info-card { min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: 24px; padding: clamp(18px, 1.7vw, 32px); display: flex; flex-direction: column; overflow: auto; container-type: size; }
.info-card > * { flex-shrink: 0; }
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.15; font-weight: 600; letter-spacing: -.03em; margin: 0 0 18px; }
@@ -101,6 +101,34 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
+.service-info { container-name: service-card; }
+.service-window { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 20px; }
+.service-boundary { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 12px; }
+.service-boundary + .service-boundary { border-top: 1px solid var(--line); padding-top: 20px; }
+.service-label { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--secondary); font-size: clamp(17px, 3cqw, 24px); line-height: 1.25; }
+.service-label span { color: var(--muted); font-size: .8em; align-self: center; }
+.service-boundary > time { display: block; color: var(--accent); font-size: clamp(44px, min(17cqw, 19cqh), 112px); font-weight: 600; line-height: 1; }
+.service-trip { min-width: 0; display: flex; align-items: center; gap: 12px; font-size: clamp(18px, 4cqw, 26px); line-height: 1.2; }
+.service-trip .route-badge { flex-shrink: 0; width: 72px; height: 48px; border-radius: 14px; font-size: 32px; padding: 0 8px; }
+.service-trip > span:last-child { overflow-wrap: anywhere; }
+.service-info .service-empty { margin: 0; font-size: 20px; }
+.service-info .service-unavailable { margin-block: auto; text-align: center; font-size: 22px; }
+@container service-card (min-width: 600px) {
+ .service-window { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 32px; }
+ .service-boundary + .service-boundary { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 32px; }
+}
+@container service-card (max-height: 380px) {
+ .service-window { gap: 12px; }
+ .service-boundary { gap: 8px; }
+ .service-boundary + .service-boundary { padding-top: 12px; }
+ .service-label { font-size: 17px; }
+ .service-boundary > time { font-size: clamp(40px, min(14cqw, 25cqh), 72px); }
+ .service-trip { font-size: 18px; }
+ .service-trip .route-badge { width: 60px; height: 40px; font-size: 28px; border-radius: 12px; }
+}
+@container service-card (min-width: 600px) and (max-height: 380px) {
+ .service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; }
+}
.weather-info { container-name: weather-card; }
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
.weather-now { min-width: 0; display: flex; flex-direction: column; align-items: center; }
@@ -404,7 +432,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-screen[data-compact='true'][data-cards='true'] { position: relative; }
.info-screen[data-compact='true'][data-cards='true'] .info-intro { position: absolute; top: 27px; right: 20px; z-index: 1; margin: 0; }
.info-screen[data-compact='true'][data-cards='true'] .info-intro h2 { display: none; }
- .info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: 290px; min-height: 36px; text-align: left; display: flex; align-items: center; }
+ .info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: var(--info-tabs-width, 290px); min-height: 36px; text-align: left; display: flex; align-items: center; }
.info-status { font-size: 13px; padding-top: 10px; }
.persistent-next > .eyebrow { display: none; }
.persistent-next { padding: 10px 0; }
@@ -415,6 +443,69 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.weather-attribution { padding-top: 8px; font-size: 10px; }
.weather-info { padding: 16px; }
}
+/* Viewing distance raises type sizes; pagination preserves the available height. */
+.board { --reading-scale: 1; }
+.board[data-profile='street'], .board[data-distance='medium'], .board[data-distance='far'] { --hero-height: clamp(125px, calc(18vh * var(--reading-scale)), 270px); }
+.board h1 { font-size: clamp(30px, calc(3.8vw * var(--reading-scale)), 90px); }
+.board .clock { font-size: clamp(38px, calc(4.6vw * var(--reading-scale)), 108px); }
+.board .eyebrow { font-size: clamp(12px, calc(1.05vw * var(--reading-scale)), 26px); }
+.board .source-label { font-size: clamp(13px, calc(1.15vw * var(--reading-scale)), 28px); }
+.board .hero-destination h2 { font-size: min(calc(4.7vw * var(--reading-scale)), calc(var(--hero-height) * .34)); }
+.board .row-destination h3 { font-size: min(calc(54px * var(--reading-scale)), calc(3.5vw * var(--reading-scale)), calc(var(--row-height) * .4)); }
+.board .row-time { font-size: min(calc(42px * var(--reading-scale)), calc(var(--row-height) * .36)); }
+.board .info-card h3 { font-size: clamp(23px, calc(2vw * var(--reading-scale)), 44px); }
+.departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(134px, calc(18vh * var(--reading-scale) + 24px), 260px); }
+.board[data-row-count='5'] .departure-content[data-next='true']:not([data-long-wait='true']), .board[data-row-count='6'] .departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(118px, calc(15vh * var(--reading-scale) + 24px), 224px); }
+.hero-next { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-top: 8px; color: var(--secondary); font-size: min(calc(22px * var(--reading-scale)), max(15px, calc(var(--hero-height) * .13))); line-height: 1.2; }
+.hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; }
+.departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; }
+.departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; }
+.cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; }
+.cancelled-label svg { width: .9em; height: .9em; }
+.departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); }
+.departure-row[data-status='cancelled'] h3 { color: var(--secondary); }
+.empty-departures[data-cancelled='true'] > svg { display: none; }
+.empty-departures[data-cancelled='true'] h2 { color: var(--warning); font-size: clamp(22px, calc(2.8vw * var(--reading-scale)), 44px); }
+.departure-content[data-long-wait='true'] { --hero-height: clamp(142px, calc(24vh * var(--reading-scale)), 300px); }
+.long-wait-time { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; }
+.long-wait-time > span:first-child { color: var(--secondary); font-size: clamp(17px, calc(2vw * var(--reading-scale)), 34px); }
+.long-wait-time > time { color: var(--accent); font-size: min(calc(144px * var(--reading-scale)), calc(9vw * var(--reading-scale)), calc(var(--hero-height) * .7)); line-height: 1; font-weight: 600; white-space: nowrap; }
+.board[data-motion='calm'] .numeric-old, .board[data-motion='calm'] .destination-old { display: none; }
+.board[data-motion='calm'] .numeric-current, .board[data-motion='calm'] .destination-current { transform: none !important; }
+.board[data-motion='calm'] .numeric-glyph[data-rolling] .numeric-current, .board[data-motion='calm'] .destination-current[data-changing='true'], .board[data-motion='calm'] .scene { animation: calm-in 180ms ease-out both; }
+@keyframes calm-in { from { opacity: .35; } to { opacity: 1; } }
+@media (max-width: 1000px) {
+ .board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(4vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
+ .board .row-time-compact { font-size: min(calc(20px * var(--reading-scale)), calc(var(--row-height) * .21)); }
+}
+@media (max-height: 620px) and (min-width: 701px) {
+ .board h1 { font-size: calc(32px * var(--reading-scale)); }
+ .board .clock { font-size: calc(40px * var(--reading-scale)); }
+}
+@media (max-width: 700px) {
+ .board h1 { font-size: clamp(25px, calc(6.5vw * var(--reading-scale)), 54px); }
+ .board .clock { font-size: clamp(27px, calc(6.8vw * var(--reading-scale)), 56px); }
+ .board .eyebrow { font-size: calc(10px * var(--reading-scale)); }
+ .board .source-label { font-size: calc(12px * var(--reading-scale)); }
+ .board .hero-destination h2 { font-size: min(calc(6vw * var(--reading-scale)), calc(var(--hero-height) * .3)); }
+ .board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(5.7vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
+ .board .info-card h3 { font-size: min(calc(24px * var(--reading-scale)), 7vw); }
+ .hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; }
+ .departure-status { gap: 4px; }
+ .departure-change { font-size: 12px; }
+ .hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
+ .hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); }
+ .hero-departure[data-long-wait='true'] .transport-kind { display: none; }
+ .long-wait-time { grid-column: 2; align-items: flex-start; gap: 4px; }
+ .long-wait-time > span:first-child { font-size: 16px; }
+ .long-wait-time > time { font-size: min(56px, 14vw); }
+ .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
+ .board[data-distance='far'] .departure-content:not([data-long-wait='true']) { --hero-height: clamp(142px, calc(19vh * var(--reading-scale)), 220px); }
+ .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); }
+ .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; }
+ .board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; }
+ .board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; }
+}
::view-transition { pointer-events: none; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
@media (prefers-reduced-motion: reduce) {
diff --git a/src/types.ts b/src/types.ts
index afcd87b..05ea109 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,18 +1,23 @@
export type Departure = {
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
+ serviceDate?: string; originStopId?: number; originTime?: string;
};
export type Route = { id: number; number: string; name: string; type: string };
-export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null };
+export type Cancellation = { routeNumber: string; stopId: number; serviceDate: string; time: string };
+export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null; cancellations?: Cancellation[] };
export type Stop = { id: number; name: string; direction: string };
export type BoardData = { cityId: number; cityName: string; stop: Stop; departures: Departure[]; routes: Route[];
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
- rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
+ rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
+ infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
+ showNextSameRoute: boolean; longWaitEnabled: boolean; displayProfile: 'tv' | 'street'; viewingDistance: 'near' | 'medium' | 'far';
+ adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
export type Scene = 'departures' | 'info' | 'ad';
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
- dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
+ dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string }[];
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
diff --git a/test/board.test.mjs b/test/board.test.mjs
index 7f5879e..d5dd121 100644
--- a/test/board.test.mjs
+++ b/test/board.test.mjs
@@ -85,6 +85,14 @@ test('city and platform identity are checked before restoring a snapshot', () =>
assert.equal(isBoardData(data, 1, 2), false);
assert.equal(isBoardData({ ...data, mode: 'demo' }, 1, 1), false);
});
+test('malformed cancellation targets in a saved snapshot are rejected instead of cancelling unrelated trips', () => {
+ const target = { routeNumber: '5', stopId: 1, serviceDate: '2026-10-08', time: '12:35' };
+ const alert = { id: '1', routeIds: [37], routeNumbers: ['5'], cancellations: [target] };
+ const snapshot = { ...makeSnapshot(), alerts: [alert] };
+ assert.equal(isBoardData(snapshot, 1, 1), true);
+ for (const damaged of [null, { ...target, stopId: -1 }, { ...target, serviceDate: '2026-02-31' }, { ...target, time: '25:00' }])
+ assert.equal(isBoardData({ ...snapshot, alerts: [{ ...alert, cancellations: [damaged] }] }, 1, 1), false);
+});
test('route filtering and transport priority use absolute arrival time', () => {
const data = makeSnapshot();
assert.equal(upcomingDepartures(data, now, '88').length, 0);
diff --git a/test/departure-features.test.mjs b/test/departure-features.test.mjs
new file mode 100644
index 0000000..518ffaf
--- /dev/null
+++ b/test/departure-features.test.mjs
@@ -0,0 +1,77 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { normalizeAlerts, normalizeDepartures } from '../server/board.mjs';
+import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
+import { displayDepartures, firstAvailable, nextSameRoute, longWait, readability } from '../src/lib/departures.ts';
+import { defaultSettings } from '../src/lib/storage.ts';
+
+const now = Date.parse('2026-10-09T12:00:00+03:00');
+const stops = [{ id: 1, name: 'Швейная фабрика' }, { id: 2, name: 'Мариненко' }];
+const routes = [{ id: 6, number: '6' }, { id: 1, number: '1' }];
+const departure = (id, routeId, time, extra = {}) => ({ id, routeId, routeNumber: String(routeId), destination: 'Автовокзал', scheduledAt: `2026-10-09T${time}:00+03:00`, scheduledTime: time, ...extra });
+const raw = { id: 1, city_id: 1, route_id: 6, alert_type: 'cancellation', message: '09.10.2026: не выполняются рейсы:\nШвейная фабрика — 12:04', start_time: '2026-10-09T00:00:00+03:00', end_time: '2026-10-10T00:00:00+03:00' };
+const alerts = normalizeAlerts([raw], routes, 1, now, stops);
+
+test('reviewed cancellation format binds date, route, exact stop and clock; generic prose never cancels every trip', () => {
+ assert.deepEqual(alerts[0].cancellations, [{ routeNumber: '6', stopId: 1, serviceDate: '2026-10-09', time: '12:04' }]);
+ const result = displayDepartures([departure('a', 6, '12:04'), departure('b', 1, '12:08'), departure('c', 6, '12:12')], alerts, 1, now, true);
+ assert.deepEqual(result.map(item => item.status), ['cancelled', 'scheduled', 'notice']);
+ assert.equal(firstAvailable(result).id, 'b');
+ const generic = normalizeAlerts([{ ...raw, message: 'Один из рейсов отменён, уточните информацию.' }], routes, 1, now, stops);
+ assert.equal(displayDepartures([departure('a', 6, '12:04')], generic, 1, now, true)[0].status, 'notice');
+});
+test('multi-route notices keep each route and stop separate; ambiguous platform names and malformed notices do not match', () => {
+ const message = '09.10.2026: не выполняются рейсы:\nМаршрут №6:\nШвейная фабрика — 12:04, 13:05\n\nМаршрут №1:\nМариненко — 12:10';
+ const targets = parseCancellationNotice(message, ['6', '1'], stops);
+ assert.equal(targets.length, 3); assert.equal(targets[2].routeNumber, '1'); assert.equal(targets[2].stopId, 2);
+ assert.deepEqual(parseCancellationNotice(message, ['6'], stops), []);
+ assert.deepEqual(parseCancellationNotice(raw.message, ['6'], [...stops, { id: 3, name: 'Швейная фабрика' }]), []);
+ assert.deepEqual(parseCancellationNotice(raw.message.replace('12:04', '25:04'), ['6'], stops), []);
+ assert.deepEqual(parseCancellationNotice(raw.message.replace('09.10.2026', '31.02.2026'), ['6'], stops), []);
+});
+test('matching an origin cancellation reaches downstream stops and retains the previous service date after midnight', () => {
+ const trip = departure('a', 6, '12:14', { originStopId: 1, originTime: '12:04', serviceDate: '2026-10-09' });
+ assert.equal(displayDepartures([trip], alerts, 2, now, true)[0].status, 'cancelled');
+ const overnight = departure('b', 6, '00:14', { scheduledAt: '2026-10-10T00:14:00+03:00', originStopId: 1, originTime: '23:54', serviceDate: '2026-10-09' });
+ const overnightAlerts = normalizeAlerts([{ ...raw, message: raw.message.replace('12:04', '23:54') }], routes, 1, now, stops);
+ assert.equal(displayDepartures([overnight], overnightAlerts, 2, now, true)[0].status, 'cancelled');
+ assert.equal(displayDepartures([{ ...overnight, serviceDate: '2026-10-10' }], overnightAlerts, 2, now, true)[0].status, 'notice');
+});
+test('expired, not-yet-active or stale cancellation data cannot remove a departure; ambiguous simultaneous trips remain available', () => {
+ const trips = [departure('a', 6, '12:04')];
+ assert.equal(displayDepartures(trips, alerts, 1, now, false)[0].status, 'scheduled');
+ assert.equal(displayDepartures(trips, [{ ...alerts[0], endAt: new Date(now).toISOString() }], 1, now, true)[0].status, 'scheduled');
+ assert.equal(displayDepartures(trips, [{ ...alerts[0], startAt: new Date(now + 1000).toISOString() }], 1, now, true)[0].status, 'scheduled');
+ const ambiguous = displayDepartures([...trips, departure('other-direction', 66, '12:04', { routeNumber: '6', destination: 'Калинина' })], [{ ...alerts[0], routeNumbers: ['6'] }], 1, now, true);
+ assert.deepEqual(ambiguous.map(item => item.status), ['notice', 'notice']);
+ assert.equal(firstAvailable([{ ...trips[0], status: 'cancelled' }]), undefined);
+});
+test('next departure uses the same route and destination, excludes duplicates and cancellations, and crosses midnight', () => {
+ const first = { ...departure('a', 6, '23:54'), status: 'scheduled' };
+ const next = { ...departure('d', 6, '00:40', { scheduledAt: '2026-10-10T00:40:00+03:00' }), status: 'scheduled' };
+ const list = [first, { ...first, id: 'same-clock' }, { ...first, id: 'cancelled', scheduledAt: '2026-10-10T00:10:00+03:00', status: 'cancelled' },
+ { ...first, id: 'opposite', scheduledAt: '2026-10-10T00:20:00+03:00', destination: 'Калинина' }, next];
+ assert.equal(nextSameRoute(list, first), next);
+ assert.equal(nextSameRoute([first], first), undefined);
+ assert.equal(nextSameRoute(list, undefined), undefined);
+});
+test('long wait starts at 90 minutes, can be disabled, and turns off as an arrival approaches', () => {
+ const trip = departure('a', 6, '13:30');
+ assert.equal(longWait(defaultSettings, trip, now), true);
+ assert.equal(longWait(defaultSettings, trip, now + 1), false);
+ assert.equal(longWait(defaultSettings, trip, now + 60000), false);
+ assert.equal(longWait({ ...defaultSettings, longWaitEnabled: false }, trip, now), false);
+ assert.equal(longWait(defaultSettings, undefined, now), false);
+});
+test('viewing distance increases text and minimum row height, while street animation is calmer', () => {
+ const near = readability(defaultSettings), far = readability({ ...defaultSettings, viewingDistance: 'far' });
+ const street = readability({ ...defaultSettings, displayProfile: 'street', viewingDistance: 'far' });
+ assert.equal(near.scale, 1); assert.equal(near.rowHeight, 62);
+ assert.ok(far.scale > near.scale && far.rowHeight > near.rowHeight);
+ assert.ok(street.scale > far.scale && street.rowHeight > far.rowHeight); assert.equal(street.calm, true);
+});
+test('short live response preserves origin identity and normalizes one-digit hours for exact matching', () => {
+ const result = normalizeDepartures({ routes: [{ id: 6, route_number: '6', name: 'А — Б', arrivals: [{ scheduledTime: '9:04', trip: { departureTime: '8:54', startStopId: 1 } }] }] }, Date.parse('2026-10-09T09:00:00+03:00'));
+ assert.equal(result.departures[0].scheduledTime, '09:04'); assert.equal(result.departures[0].originTime, '08:54');
+ assert.equal(result.departures[0].originStopId, 1); assert.equal(result.departures[0].serviceDate, '2026-10-09');
+});
diff --git a/test/fixtures/board-features.html b/test/fixtures/board-features.html
new file mode 100644
index 0000000..8e33e22
--- /dev/null
+++ b/test/fixtures/board-features.html
@@ -0,0 +1 @@
+RideBus — проверка режимов
diff --git a/test/fixtures/board-features.tsx b/test/fixtures/board-features.tsx
new file mode 100644
index 0000000..3161498
--- /dev/null
+++ b/test/fixtures/board-features.tsx
@@ -0,0 +1,61 @@
+import { useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import '@fontsource-variable/manrope';
+import '../../src/styles.css';
+import { DeparturesScreen } from '../../src/App';
+import { defaultSettings } from '../../src/lib/storage';
+import { displayDepartures, readability } from '../../src/lib/departures';
+import { normalizeAlerts } from '../../server/board.mjs';
+import { colorScheme } from '../../src/lib/presentation.mjs';
+import type { BoardData, Departure, Settings } from '../../src/types';
+import type { CSSProperties } from 'react';
+
+const stop = { id: 1, name: 'Швейная фабрика', direction: '' };
+function Fixture() {
+ const [scenario, setScenario] = useState('cancellation');
+ const [settings, setSettings] = useState({ ...defaultSettings, rowCount: 6 });
+ const [paused, setPaused] = useState(true);
+ const [approaching, setApproaching] = useState(false);
+ const tomorrow = scenario === 'tomorrow';
+ const distant = tomorrow || scenario === 'today';
+ const baseNow = Date.parse(tomorrow ? '2026-10-09T23:20:00+03:00' : '2026-10-09T12:00:00+03:00');
+ const routeNumbers = ['6', '1', '6', '2тк', '1', '17тк', '21тк', '6'];
+ const minutes = distant ? [125, 140, 165, 185, 195, 210, 220, 240] : [4, 8, 12, 17, 23, 31, 38, 44];
+ const departures: Departure[] = minutes.map((minutes, index) => {
+ const at = tomorrow ? Date.parse('2026-10-10T05:40:00+03:00') + (minutes - 125) * 60000 : baseNow + minutes * 60000;
+ const number = routeNumbers[index];
+ const destination = number === '1' ? 'Богатырская' : number === '6' ? 'Автовокзал' : number === '2тк' ? 'Больница (Новка) (через Аэропорт)' : 'Тросницкая';
+ return { id: String(index), routeId: index === 3 ? 20 : Number.parseInt(number), routeNumber: number,
+ routeName: `Швейная фабрика — ${destination}`, destination, type: number.includes('тк') ? 'minibus' : 'bus', scheduledAt: new Date(at).toISOString(),
+ scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(at), note: '', source: 'schedule' };
+ });
+ const now = approaching ? Date.parse(departures[0].scheduledAt) - 10 * 60000 : baseNow;
+ const routes = [...new Map(departures.map(item => [item.routeId, { id: item.routeId, number: item.routeNumber, name: item.routeName, type: item.type }])).values()];
+ const all = scenario === 'all-cancelled';
+ const message = all ? `09.10.2026: не выполняются рейсы:\n${routes.map(route => `Маршрут №${route.number}:\nШвейная фабрика — ${departures.filter(item => item.routeId === route.id).map(item => item.scheduledTime).join(', ')}`).join('\n')}`
+ : '09.10.2026: не выполняются рейсы:\nШвейная фабрика — 12:04';
+ const alerts = distant ? [] : normalizeAlerts([{ id: 1, city_id: 1, route_numbers: all ? routes.map(item => item.number) : ['6'], alert_type: 'cancellation', title: 'Отмена отдельных рейсов', message, start_time: '2026-10-09T00:00:00+03:00', end_time: '2026-10-10T00:00:00+03:00' }], routes, 1, now, [stop]);
+ const data: BoardData = { cityId: 1, cityName: 'Полоцк', stop, departures, routes, alerts, alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: '2026-10-09', mode: 'demo' };
+ const reading = readability(settings);
+ const update = (key: K, value: Settings[K]) => setSettings(previous => ({ ...previous, [key]: value }));
+ return ;
+}
+createRoot(document.getElementById('root')!).render( );
diff --git a/test/information.test.mjs b/test/information.test.mjs
new file mode 100644
index 0000000..1653d37
--- /dev/null
+++ b/test/information.test.mjs
@@ -0,0 +1,42 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { boardScenes, informationBlocks, serviceWindow } from '../src/lib/information.ts';
+import { defaultSettings } from '../src/lib/storage.ts';
+
+const off = { ...defaultSettings, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false };
+const trip = (routeId, at) => ({ routeId, routeNumber: String(routeId), scheduledAt: at });
+
+test('disabled information blocks are omitted without leaving gaps in the cycle', () => {
+ assert.deepEqual(informationBlocks(defaultSettings).map(block => block.id), ['service', 'weather', 'qr']);
+ assert.deepEqual(informationBlocks({ ...off, infoQrEnabled: true }).map(block => block.id), ['qr']);
+ assert.deepEqual(informationBlocks({ ...defaultSettings, infoWeatherEnabled: false }).map(block => block.id), ['service', 'qr']);
+ assert.deepEqual(informationBlocks(off), []);
+});
+test('all blocks off keeps departures, retains optional advertising, and still surfaces alerts', () => {
+ assert.deepEqual(boardScenes(off, false, false), ['departures']);
+ assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, false, false), ['departures', 'ad']);
+ assert.deepEqual(boardScenes({ ...off, adsEnabled: true }, true, true), ['departures', 'info']);
+ assert.deepEqual(boardScenes(off, true, false), ['departures', 'info']);
+});
+test('service boundaries use local arrival dates, including after-midnight trips', () => {
+ const now = Date.parse('2026-10-09T23:00:00+03:00');
+ const last = trip(2, '2026-10-09T23:50:00+03:00');
+ const first = trip(1, '2026-10-09T21:15:00Z'); // 00:15 tomorrow in Minsk
+ const departures = [trip(1, '2026-10-10T06:00:00+03:00'), last, first,
+ trip(1, '2026-10-09T00:10:00+03:00'), trip(1, '2026-10-08T23:59:00+03:00')];
+ assert.deepEqual(serviceWindow(departures, now, ''), { lastToday: last, firstTomorrow: first });
+});
+test('route selection scopes both boundaries; empty days do not invent a trip', () => {
+ const now = Date.parse('2026-10-09T12:00:00+03:00');
+ const filtered = trip(1, '2026-10-09T20:00:00+03:00');
+ const departures = [filtered, trip(2, '2026-10-09T23:00:00+03:00'), trip(2, '2026-10-10T05:00:00+03:00')];
+ assert.deepEqual(serviceWindow(departures, now, '1'), { lastToday: filtered, firstTomorrow: null });
+ assert.deepEqual(serviceWindow([], now, ''), { lastToday: null, firstTomorrow: null });
+});
+test('service boundaries roll to the new day and across the year at local midnight', () => {
+ const last = trip(1, '2026-12-31T23:59:00+03:00');
+ const first = trip(1, '2027-01-01T05:00:00+03:00');
+ const next = trip(1, '2027-01-02T06:00:00+03:00');
+ assert.deepEqual(serviceWindow([last, first, next], Date.parse('2026-12-31T23:59:00+03:00'), ''), { lastToday: last, firstTomorrow: first });
+ assert.deepEqual(serviceWindow([last, first, next], Date.parse('2027-01-01T00:00:00+03:00'), ''), { lastToday: first, firstTomorrow: next });
+});
diff --git a/test/storage.test.mjs b/test/storage.test.mjs
index 8a05ef4..f86d699 100644
--- a/test/storage.test.mjs
+++ b/test/storage.test.mjs
@@ -41,3 +41,26 @@ test('old theme preferences migrate safely and each theme retains its own accent
values.set('ridebus-board-settings-v1', JSON.stringify({ theme: 'light', lightColorScheme: 'lime' }));
assert.equal(readSettings().lightColorScheme, 'blue');
});
+test('information switches migrate from old settings and persist every disabled block', () => {
+ const values = new Map([['ridebus-board-settings-v1', JSON.stringify({ rowCount: 6 })]]);
+ globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
+ const old = readSettings();
+ assert.equal(old.infoServiceEnabled, true); assert.equal(old.infoWeatherEnabled, true); assert.equal(old.infoQrEnabled, true);
+ saveSettings({ ...old, infoServiceEnabled: false, infoWeatherEnabled: false, infoQrEnabled: false });
+ const disabled = readSettings();
+ assert.equal(disabled.infoServiceEnabled, false); assert.equal(disabled.infoWeatherEnabled, false); assert.equal(disabled.infoQrEnabled, false);
+ assert.equal(disabled.rowCount, 6);
+ saveSettings({ ...disabled, infoQrEnabled: true });
+ assert.equal(readSettings().infoQrEnabled, true); assert.equal(readSettings().infoWeatherEnabled, false);
+});
+test('readability and departure display preferences migrate safely and survive reload', () => {
+ const values = new Map([['ridebus-board-settings-v1', '{}']]);
+ globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
+ const old = readSettings();
+ assert.equal(old.displayProfile, 'tv'); assert.equal(old.viewingDistance, 'near'); assert.equal(old.showNextSameRoute, true); assert.equal(old.longWaitEnabled, true);
+ saveSettings({ ...old, displayProfile: 'street', viewingDistance: 'far', showNextSameRoute: false, longWaitEnabled: false });
+ const saved = readSettings();
+ assert.equal(saved.displayProfile, 'street'); assert.equal(saved.viewingDistance, 'far'); assert.equal(saved.showNextSameRoute, false); assert.equal(saved.longWaitEnabled, false);
+ values.set('ridebus-board-settings-v1', JSON.stringify({ displayProfile: 'invalid', viewingDistance: 'invalid' }));
+ assert.equal(readSettings().displayProfile, 'tv'); assert.equal(readSettings().viewingDistance, 'near');
+});
diff --git a/test/timetable.test.mjs b/test/timetable.test.mjs
index 63d7ce4..0f64b60 100644
--- a/test/timetable.test.mjs
+++ b/test/timetable.test.mjs
@@ -43,6 +43,8 @@ test('previous service day retains after-midnight arrivals, with no time wrappin
assert.equal(departures.length, 1);
assert.equal(departures[0].scheduledTime, '00:10');
assert.equal(departures[0].scheduledAt, '2026-10-08T21:10:00.000Z');
+ assert.equal(departures[0].serviceDate, '2026-10-08');
+ assert.equal(departures[0].originTime, '23:55'); assert.equal(departures[0].originStopId, 1);
});
test('partial and reverse trips use the start offset and correct terminal', async () => {
const data = await bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 3, endStopId: 1, note: 'Обратно' }, { departureTime: '13:00', startStopId: 3, endStopId: 3 }])]);