import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Check, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons'; import { NumericText } from './NumericText'; import { RouteBadge } from './RouteBadge'; 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 logo from './assets/ridebus-logo.svg?raw'; import { colorScheme, departurePage, stopDirection } from './lib/presentation.mjs'; import type { 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'; import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks'; import { SettingsPanel } from './SettingsPanel'; const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' }); const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }); const sceneNames: Record = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' }; function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) { const minutes = minutesUntil(departure, now); if (localDate(Date.parse(departure.scheduledAt)) > localDate(now)) return
завтра
; return
0 ? `Через ${minutes} минут по расписанию` : 'Сейчас по расписанию'}> {minutes > 0 ? : 'сейчас'}{minutes > 0 && мин}
; } function AlertCard({ alert, compact = false }: { alert: Alert; compact?: boolean }) { return
{alert.type === 'cancellation' ? 'Отмена рейсов' : 'Изменения движения'}
{!!alert.routeNumbers.length &&
{alert.routeNumbers.map(number => `№${number}`).join(' · ')}
}

{alert.title}

{alert.message}

; } 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; }) { const first = departures[0]; const table = useRef(null); const [tableHeight, setTableHeight] = useState(360); const [page, setPage] = useState(0); const capacity = Math.max(2, Math.floor(tableHeight / 50)); const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page); const rows = Math.max(1, Math.min(rowCount, visible.size)); useLayoutEffect(() => { if (!table.current) return; const observer = new ResizeObserver(entries => setTableHeight(entries[0].contentRect.height)); observer.observe(table.current); return () => observer.disconnect(); }, []); useEffect(() => { if (paused || visible.pages < 2) return; const timer = setInterval(() => setPage(value => value + 1), 7000); return () => clearInterval(timer); }, [paused, visible.pages]); return
БЛИЖАЙШИЙ РЕЙС По расписанию{data?.mode === 'demo' ? ' · демо' : ''}
{first ?
{first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}

{first.destination}

{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} : }
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}> {visible.items.map((departure, index) =>

{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}

)} {first && departures.length === 1 &&

Это последний доступный рейс

} {!first &&
}
{alerts[0] &&
{alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}
}
; } function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, timetable, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void; config: Config; timetable: ReturnType; weather: ReturnType; now: number }) { const panel = useRef(null); useEffect(() => { const element = panel.current; if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return; let timer: ReturnType; const start = setTimeout(() => { const overflow = element.scrollHeight - element.clientHeight; if (overflow > 0) timer = setInterval(() => { element.scrollTop += overflow / (Math.max(1, seconds - 7) * 10); }, 100); }, 3500); return () => { clearTimeout(start); clearInterval(timer); }; }, [seconds, paused, page]); return

{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}

{!alerts.length && }
{alerts.length ?
{alerts.map(alert => )}
:

Маршруты остановки

{[...new Set(data?.routes.map(route => route.number))].map(number => {number})}
{timetableAvailable(timetable.data, now) && timetable.saved ? `Полное расписание сохранено · маршрутов: ${timetable.data?.routes.length}` : timetable.loading ? 'Сохраняем полное расписание…' : timetable.saved === false ? 'Не удалось сохранить полный кеш' : 'Полный кеш сейчас недоступен'}

Время по расписанию. Фактическое прибытие зависит от дорожной обстановки.

} {!alerts.length &&
{data?.alertsAvailable === false || !fresh ? 'Актуальные предупреждения сейчас недоступны' : 'Нет активных предупреждений для этой остановки'}
}
; } export default function App() { const [settings, setSettings] = useState(() => { const saved = readSettings(); if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true; return saved; }); const [config, setConfig] = useState(fallbackConfig); const [settingsOpen, setSettingsOpen] = useState(false); const controlsVisible = useIdleControls(settingsOpen); const [scene, setScene] = useState('departures'); const [infoPage, setInfoPage] = useState(0); const [paused, setPaused] = useState(false); const [startedAt, setStartedAt] = useState(Date.now()); const [notice, setNotice] = useState(''); const [fullscreen, setFullscreen] = useState(false); const noticeTimer = useRef | null>(null); const { data: liveData, failed, loading, offset, error, offlineSaved, refresh } = useBoard(settings, config); const timetable = useTimetable(settings, config), weather = useWeather(settings, config); const now = useClock(offset); const date = localDate(now); 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 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 urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour'); const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info']; 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 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.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)); else change(); }, [showNotice]); const nextScene = useCallback(() => { const state = sceneState.current; goTo(state.scenes[(state.scenes.indexOf(state.scene) + 1) % state.scenes.length]); }, [goTo]); const toggleFullscreen = useCallback(async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch { showNotice('Полный экран недоступен в этом браузере'); } }, [showNotice]); useEffect(() => { const controller = new AbortController(); fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => { setConfig(value); if (!hasSavedSettings() && new URLSearchParams(location.search).get('demo') !== '1') setSettings(previous => ({ ...previous, stopId: value.defaultStopId, demo: !value.configured })); }).catch(() => {}); return () => controller.abort(); }, []); useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]); useEffect(() => { 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 (paused || settingsOpen) { setStartedAt(Date.now()); return; } if (!held && elapsed >= seconds * 1000) nextScene(); }, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]); useEffect(() => { function keydown(event: KeyboardEvent) { if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return; if (event.code === 'KeyS') { event.preventDefault(); setSettingsOpen(value => !value); } if (settingsOpen) return; if (event.code === 'KeyF') { event.preventDefault(); void toggleFullscreen(); } if (event.code === 'Space') { event.preventDefault(); setPaused(value => !value); setStartedAt(Date.now()); } if (event.code === 'ArrowRight') { event.preventDefault(); nextScene(); } } const changed = () => setFullscreen(Boolean(document.fullscreenElement)); document.addEventListener('keydown', keydown); document.addEventListener('fullscreenchange', changed); return () => { document.removeEventListener('keydown', keydown); document.removeEventListener('fullscreenchange', changed); }; }, [settingsOpen, toggleFullscreen, nextScene]); useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []); 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

{data?.stop.name || config.stopName}

{direction &&
{direction}
}
{dateFormat.format(now)}
{!fresh && !settings.demo &&
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
}
{scene === 'departures' && } {scene === 'info' && } {scene === 'ad' &&
РЕКЛАМА · ПРЕДПРОСМОТР

{settings.adTitle}

{settings.adText}

Время для нового.
}
{scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{departures[0] ? <>{departures[0].destination}По расписанию : Нет доступных рейсов}
}
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
{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('Настройки сохранены'); }} />}
; }