feat: implement night mode functionality with UI updates and journey tracking

- Added NightScreen component for displaying night mode information and alerts.
- Introduced NextStops component to show upcoming stops during a journey.
- Implemented logic for determining night mode activation based on schedule and user settings.
- Created journey utility functions to manage onward journey data and stop information.
- Updated tests to cover new night mode features and journey logic.
- Added fixture for testing night mode scenarios with specific timestamps.
- Removed QR code from night screen for a cleaner UI experience.
- Enhanced admin settings to manage night mode configurations.
This commit is contained in:
2026-10-11 05:04:00 +03:00
parent d04b364047
commit 5c69f6691d
49 changed files with 590 additions and 121 deletions

View File

@@ -3,14 +3,16 @@ import { flushSync } from 'react-dom';
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 { ServiceWindow } from './ServiceWindow';
import { NextStops } from './NextStops';
import { AlertCard } from './AlertCard';
import { FittedDestination } from './FittedDestination';
import { useIdleControls } from './useIdleControls';
import { PassengerQr } from './PassengerQr';
import { WeatherCard, WeatherSummary } from './WeatherBlock';
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
import { boardScenes, informationBlocks, serviceWindow } from './lib/information';
import { boardScenes, informationBlocks } from './lib/information';
import { onwardJourney } from './lib/journey';
import { demoJourney } from './lib/demo';
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
import type { DisplayDeparture } from './lib/departures';
import logo from './assets/ridebus-logo.svg?raw';
@@ -24,6 +26,8 @@ import { SettingsPanel } from './SettingsPanel';
import { useDeviceControl } from './useDeviceControl';
import type { ManagedState } from './useDeviceControl';
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
import { nightWindow } from './lib/night';
import { NightScreen } from './NightScreen';
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' });
@@ -100,13 +104,14 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
</div>;
}
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<typeof useWeather>; now: number; settings: Settings; service: ComponentProps<typeof ServiceWindow> }) {
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void }) {
const panel = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
const blocks = informationBlocks(settings);
const activeBlock = blocks[page % blocks.length]?.id;
useEffect(() => { if (alerts.length || !settings.infoServiceEnabled || compact && activeBlock !== 'service') onJourneyPages(1); }, [alerts.length, settings.infoServiceEnabled, compact, activeBlock, onJourneyPages]);
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 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
@@ -129,7 +134,7 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
{!alerts.length && blocks.length > 1 && <nav className="information-tabs" aria-label="Блоки информации">{blocks.map((block, index) => <button key={block.id} aria-current={activeBlock === block.id ? 'page' : undefined} onClick={() => onPage(index)}>{block.name}</button>)}</nav>}</div>
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
{block.id === 'service' && <ServiceWindow {...service} />}
{block.id === 'service' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} onPages={onJourneyPages} />}
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} />}
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
</section>)}
@@ -158,6 +163,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
const controlsVisible = useIdleControls(settingsOpen);
const [scene, setScene] = useState<Scene>('departures');
const [infoPage, setInfoPage] = useState(0);
const [journeyPages, setJourneyPages] = useState(1);
const [paused, setPaused] = useState(false);
const [startedAt, setStartedAt] = useState(Date.now());
const [notice, setNotice] = useState('');
@@ -169,6 +175,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
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 nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : [], [settings.nightMode, timetable.data, date, fullDepartures]);
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
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 || []);
@@ -179,17 +186,19 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
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 journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId), [settings.demo, settings.stopId, config.cityId, timetable.data, first, now]);
const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4)))));
const night = nightWindow(settings, displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), now, nightAvailable);
const reading = readability(settings);
const held = shouldHoldScene(settings, first, now);
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
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 scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent, night.active]);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0, 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, calm: reading.calm });
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm };
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active });
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active };
const showNotice = useCallback((text: string) => {
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
@@ -237,7 +246,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
if (held) { setScene('departures'); setStartedAt(Date.now()); }
else setStartedAt(Date.now());
}, [held]);
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent && scenes.includes('info') ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
useEffect(() => {
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
@@ -259,22 +268,24 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
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 <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={settings.theme} data-row-count={settings.rowCount} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-motion={reading.calm ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
const theme = night.active ? 'dark' : settings.theme;
return <div className={`board ${!night.active && scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={theme} data-night={night.active} data-row-count={settings.rowCount} data-profile={settings.displayProfile} data-distance={settings.viewingDistance} data-motion={reading.calm || night.active ? 'calm' : 'full'} style={{ '--reading-scale': reading.scale, '--accent': colorScheme(theme === 'light' ? settings.lightColorScheme : settings.colorScheme, theme).color } as CSSProperties}>
<header className="board-header"><div className="stop-heading"><h1>{data?.stop.name || config.stopName}</h1>
<div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div>
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${night.active ? `${timeParts[0]}:${timeParts[1]}` : clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} />{!night.active && <small><NumericText value={timeParts[2]} /></small>}</div><div className="today">{dateFormat.format(now)}</div></div>
</header>
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings}
service={{ ...service, available: fullAvailable, tomorrowAvailable: Boolean(fullAvailable && timetable.data?.calendarYears.includes(Number(shiftDate(date, 1).slice(0, 4)))), loading: timetable.loading, now }} />}
{scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />}
{!night.active && scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} />}
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages}
journey={{ departure: first, journey, loading: timetable.loading || loading, now, paused: paused || settingsOpen, settings }} />}
{!night.active && scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
</div></main>
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} /></div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
{!night.active && scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{first ? <><RouteBadge number={first.routeNumber} filled /><div className="persistent-destination"><strong>{first.destination}</strong><ScheduledTime departure={first} now={now} /></div><Countdown departure={first} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen || scenes.length < 2 ? 0 : progress})` }} /></div>
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{sceneNames[item]}</span></button>)}</nav>
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${night.active ? 'Ночной режим' : sceneNames[item]}`} title={night.active ? 'Ночной режим' : sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{night.active ? 'Ночной режим' : sceneNames[item]}</span></button>)}</nav>
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
</footer>

50
src/NextStops.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { localDate } from './lib/timetable.mjs';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import type { OnwardJourney } from './lib/journey';
import type { Departure, Settings } from './types';
import type { CSSProperties } from 'react';
export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
active: boolean; onPages: (pages: number) => void;
}) {
const [page, setPage] = useState(0);
const reading = readability(settings);
const count = journey?.stops.length || 0;
const pages = Math.max(1, count);
const focus = page % pages;
useEffect(() => { setPage(0); }, [departure?.id]);
useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
useEffect(() => {
if (paused || !active || pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 6000 : 4000);
return () => clearInterval(timer);
}, [paused, active, pages, reading.calm]);
const tomorrow = departure && localDate(Date.parse(departure.scheduledAt)) > localDate(now);
return <><h3>Дальше по маршруту</h3>
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
<div className="journey-route"><RouteBadge number={departure.routeNumber} filled /><div className="journey-trip"><div className="journey-route-name"><FittedDestination text={departure.destination} /></div><time dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{tomorrow && <span className="journey-day">завтра</span>}</time></div></div>
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
<div className="journey-metro" data-calm={reading.calm} data-more={journey.more > 0} data-loop={page > 0 && focus === 0}>
<div className="journey-tour-heading"><span>{settings.demo ? 'Демонстрация поездки' : 'После посадки'}</span><span className="journey-order">{focus + 1}-я остановка</span></div>
<div className="journey-map-space"><div className="journey-rail-window"><ol className="journey-rail" aria-label="Следующие остановки после посадки" style={{ '--journey-focus': focus } as CSSProperties}>
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} aria-current={index === focus ? 'step' : undefined}>
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span>&lt;1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
</span>
<div className="journey-station-name"><FittedDestination key={index === focus ? 'focus' : 'context'} text={stop.name} /></div>
<span className="journey-node" aria-hidden="true" />
</li>)}
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
</ol><span className="journey-direction-arrow" aria-hidden="true">›</span></div></div>
</div>
<div className="journey-caption"><span>Время пути по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
</>}
</>}
</>;
}

38
src/NightScreen.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';
import { Moon } from './Icons';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
import { AlertCard } from './AlertCard';
import { localDate } from './lib/timetable.mjs';
import type { Alert, Departure } from './types';
function NightAlerts({ alerts, paused }: { alerts: Alert[]; paused: boolean }) {
const [page, setPage] = useState(0);
const viewport = useRef<HTMLDivElement>(null);
const alert = alerts[page % alerts.length];
const duration = Math.min(90000, Math.max(25000, alert.message.length * 70 + 10000));
useEffect(() => {
if (paused) return;
const timeout = setTimeout(() => setPage(value => value + 1), duration);
return () => clearTimeout(timeout);
}, [paused, alerts.length, duration, page, alert.id]);
useEffect(() => { if (viewport.current) viewport.current.scrollTop = 0; }, [alert.id, page]);
useEffect(() => {
const element = viewport.current; if (!element) return;
if (paused) return;
let timer: ReturnType<typeof setInterval>;
const start = setTimeout(() => { timer = setInterval(() => { const overflow = element.scrollHeight - element.clientHeight; if (overflow > 0) element.scrollTop = Math.min(overflow, element.scrollTop + overflow / Math.max(1, (duration - 10000) / 100)); }, 100); }, 5000);
return () => { clearTimeout(start); clearInterval(timer); };
}, [alert.id, alert.message, paused, duration, page]);
return <div ref={viewport} className="night-alerts" aria-label="Изменения движения"><AlertCard alert={alert} />{alerts.length > 1 && <span className="night-alert-page">{page % alerts.length + 1} / {alerts.length}</span>}</div>;
}
export function NightScreen({ first, completedToday, now, alerts, paused, routeFiltered = false }: { first: Departure; completedToday: boolean; now: number; alerts: Alert[]; paused: boolean; routeFiltered?: boolean }) {
const tomorrow = localDate(Date.parse(first.scheduledAt)) > localDate(now);
return <section className="night-screen" data-alerts={alerts.length > 0} aria-label="Ночной режим">
<div className="night-intro"><span className="eyebrow"><Moon size={24} />Ночной режим</span><h2>{completedToday ? routeFiltered ? 'Рейсы маршрута на сегодня завершены' : 'Движение на сегодня завершено' : 'Доброй ночи'}</h2></div>
<div className="night-content"><div className="night-service"><span className="night-service-label">Первый рейс {tomorrow ? 'завтра' : 'сегодня'}</span><time className="night-service-time" dateTime={first.scheduledAt}><NumericText value={first.scheduledTime} /></time><div className="night-route"><RouteBadge number={first.routeNumber} filled /><h3>{first.destination}</h3></div></div>
</div>
{alerts.length > 0 && <NightAlerts alerts={alerts} paused={paused} />}
</section>;
}

View File

@@ -1,22 +0,0 @@
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 <><h3>Первый и последний рейс</h3>
{available ? <div className="service-window">
{[
{ label: 'Последний сегодня', departure: lastToday, empty: 'Сегодня рейсов нет', ended: Boolean(lastToday && Date.parse(lastToday.scheduledAt) + 60000 <= now) },
{ label: 'Первый завтра', departure: firstTomorrow, empty: tomorrowAvailable ? 'Завтра рейсов нет' : 'Расписание на завтра недоступно', ended: false },
].map(item => <div className="service-boundary" key={item.label}>
<div className="service-label">{item.label}{item.ended && <span>время прошло</span>}</div>
{item.departure ? <><time dateTime={item.departure.scheduledAt}><NumericText value={item.departure.scheduledTime} /></time>
<div className="service-trip"><RouteBadge number={item.departure.routeNumber} filled /><span>{item.departure.destination}</span></div>
</> : <p className="service-empty">{item.empty}</p>}
</div>)}
</div> : <p className="service-unavailable">{loading ? 'Загружаем полное расписание' : 'Полное расписание пока недоступно'}</p>}
</>;
}

View File

@@ -96,6 +96,8 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
<label className="switch-row"><span>Удерживать главный экран<small>Когда до рейса осталось не больше 2 минут</small></span><input type="checkbox" checked={draft.holdNear} onChange={event => update('holdNear', event.target.checked)} /></label>
<label className="switch-row"><span>Следующий рейс того же маршрута<small>В том же направлении, под ближайшим отправлением</small></span><input type="checkbox" checked={draft.showNextSameRoute} onChange={event => update('showNextSameRoute', event.target.checked)} /></label>
<label className="switch-row"><span>Режим длительного ожидания<small>От 90 минут — крупное время отправления вместо отсчёта</small></span><input type="checkbox" checked={draft.longWaitEnabled} onChange={event => update('longWaitEnabled', event.target.checked)} /></label>
<h4>Ночной режим</h4><div className="form-grid"><label>Ночной режим<select value={draft.nightMode} onChange={event => update('nightMode', event.target.value as Settings['nightMode'])}><option value="off">Выключен</option><option value="auto">Автоматически</option></select></label><label>Вернуть обычное табло<select disabled={draft.nightMode === 'off'} value={draft.nightWakeMinutes} onChange={event => update('nightWakeMinutes', Number(event.target.value) as Settings['nightWakeMinutes'])}><option value="45">За 45 минут до рейса</option><option value="60">За 60 минут до рейса</option></select></label></div>
<p className="form-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.</p>
<h4>Блоки информации</h4>
{informationOptions.map(block => <label className="switch-row" key={block.id}><span>{block.label}</span><input type="checkbox" checked={draft[block.setting]} onChange={event => update(block.setting, event.target.checked)} /></label>)}
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
@@ -108,7 +110,7 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
</section>
</div>
<div className="settings-scroll-controls" aria-label="Прокрутка настроек"><p className="settings-navigation-hint" role="status">{navigation.editing ? 'Стрелки — изменить · OK — готово' : navigation.remote ? 'Стрелки — переход · OK — выбрать' : 'Наведите курсор и нажмите OK'}</p><button type="button" className="settings-scroll-button" disabled={!scroll.up} onClick={() => { if (body.current) body.current.scrollTop -= body.current.clientHeight * .7; }}><ArrowUp size={20} /> Выше</button><button type="button" className="settings-scroll-button" disabled={!scroll.down} onClick={() => { if (body.current) body.current.scrollTop += body.current.clientHeight * .7; }}><ArrowDown size={20} /> Ниже</button></div>
<div className="settings-actions"><button type="button" className="text-button" onClick={() => { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, showNextSameRoute: true, longWaitEnabled: true, infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
<div className="settings-actions"><button type="button" className="text-button" onClick={() => { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, showNextSameRoute: true, longWaitEnabled: true, nightMode: 'auto', nightWakeMinutes: 60, infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
</form>
</dialog>;
}

View File

@@ -1,5 +1,12 @@
import type { BoardData } from '../types';
import { minskDate } from './board.mjs';
import type { Departure } from '../types';
import type { OnwardJourney } from './journey';
export function demoJourney(departure: Departure | undefined): OnwardJourney | null {
if (!departure) return null;
return { stops: ['Площадь Свободы', 'Дом торговли', 'Поликлиника', departure.destination].map((name, index) => ({ id: 1000 + index, name, minutes: [3, 6, 9, 18][index] })), more: 0 };
}
export function createDemo(now = Date.now()): BoardData {
const routes = [

View File

@@ -1,11 +1,17 @@
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
export const MANAGED_KEY = 'ridebus-board-managed-v1';
export function hydrateManagedState(value) {
if (value?.state !== 'paired' || !value.device?.settings || typeof value.device.settings !== 'object') return value;
return { ...value, device: { ...value.device, settings: { nightMode: 'auto', nightWakeMinutes: 60, ...value.device.settings } } };
}
export function isManagedState(value) {
value = hydrateManagedState(value);
const device = value?.device, s = device?.settings, config = value?.config;
if (value?.state !== 'paired' || !device || !s || !config || typeof device.id !== 'string' || typeof device.name !== 'string' || !Number.isInteger(device.revision) || device.revision < 1 || !Number.isInteger(device.cityId) || device.cityId < 1 || config.cityId !== device.cityId || config.defaultStopId !== s.stopId) return false;
if (typeof config.configured !== 'boolean' || !Number.isInteger(config.refreshSeconds) || config.refreshSeconds < 5 || config.refreshSeconds > 120 || config.timeZone !== 'Europe/Minsk') return false;
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) if (!Number.isInteger(s[key]) || s[key] < min || s[key] > max) return false;
if (s.demo !== false || !['dark', 'light'].includes(s.theme) || !['tv', 'street'].includes(s.displayProfile) || !['near', 'medium', 'far'].includes(s.viewingDistance)) return false;
if (!['off', 'auto'].includes(s.nightMode) || ![45, 60].includes(s.nightWakeMinutes)) return false;
if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false;
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string';

View File

@@ -1,8 +1,7 @@
import type { Departure, Scene, Settings } from '../types';
import { localDate, shiftDate } from './timetable.mjs';
import type { Scene, Settings } from '../types';
export const informationOptions = [
{ id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' },
{ id: 'service', setting: 'infoServiceEnabled', name: 'Маршрут', label: 'Дальше по маршруту' },
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
] as const;
@@ -15,20 +14,3 @@ export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: bool
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 };
}

23
src/lib/journey.ts Normal file
View File

@@ -0,0 +1,23 @@
import { timetableAvailable } from './timetable.mjs';
import type { Departure, Timetable } from '../types';
export type OnwardJourney = { stops: { id: number; name: string; minutes: number | null }[]; more: number };
export function onwardJourney(bundle: Timetable | null, departure: Departure | undefined, now: number, cityId: number, stopId: number): OnwardJourney | null {
if (!bundle || !departure || bundle.cityId !== cityId || bundle.stop.id !== stopId || !timetableAvailable(bundle, now)) return null;
const stops = bundle.routes.find(route => route.id === departure.routeId)?.stops;
if (!stops?.length || !departure.originStopId || !departure.endStopId) return null;
const ordered = [...stops].sort((a, b) => a.sequence - b.sequence);
const start = ordered.findIndex(stop => stop.id === departure.originStopId);
const end = ordered.findIndex(stop => stop.id === departure.endStopId);
const current = ordered.findIndex(stop => stop.id === stopId);
if (start < 0 || end < 0 || current < Math.min(start, end) || current > Math.max(start, end)) return null;
const direction = start > end ? -1 : 1;
const path = start > end ? ordered.slice(end, current).reverse() : ordered.slice(current + 1, end + 1);
const offset = ordered[current].offsetMinutes;
// Reject decreasing or incomplete offsets for duration display, keeping names.
const timed = offset !== null && path.every((stop, index) => stop.offsetMinutes !== null
&& direction * (stop.offsetMinutes - (index ? path[index - 1].offsetMinutes! : offset)) >= 0);
return { stops: path.slice(0, 4).map(stop => ({ id: stop.id, name: stop.name,
minutes: timed ? Math.ceil(direction * (stop.offsetMinutes! - offset!)) : null })), more: Math.max(0, path.length - 4) };
}

48
src/lib/night.ts Normal file
View File

@@ -0,0 +1,48 @@
import { localDate, shiftDate } from './timetable.mjs';
import type { Settings } from '../types';
import type { DisplayDeparture } from './departures';
const minute = 60000;
export function nightWindow(settings: Settings, departures: DisplayDeparture[], now: number, available: boolean) {
const inactive = { active: false as boolean, first: null as DisplayDeparture | null, completedToday: false };
if (settings.nightMode !== 'auto' || settings.demo || !available || !Number.isFinite(now)) return inactive;
const today = localDate(now), yesterday = shiftDate(today, -1);
const day = (d: DisplayDeparture) => d.serviceDate || localDate(Date.parse(d.scheduledAt));
let first: DisplayDeparture | null = null, lastToday: DisplayDeparture | undefined, lastYesterday: DisplayDeparture | undefined;
let firstAt = Infinity, firstTodayAt = Infinity, lastTodayAt = -Infinity, lastYesterdayAt = -Infinity;
const todays: { departure: DisplayDeparture; at: number }[] = [];
// The clock ticks every second; find the boundaries in one pass instead of
// re-sorting the complete three-day timetable on the TV's CPU.
for (const departure of departures) {
if (departure.status === 'cancelled' || settings.routeFilter && String(departure.routeId) !== settings.routeFilter) continue;
const at = Date.parse(departure.scheduledAt); if (!Number.isFinite(at)) continue;
const serviceDay = day(departure);
if (at + minute > now && at < firstAt) { first = departure; firstAt = at; }
if (serviceDay === today) { todays.push({ departure, at }); firstTodayAt = Math.min(firstTodayAt, at); if (at > lastTodayAt) { lastToday = departure; lastTodayAt = at; } }
if (serviceDay === yesterday && at > lastYesterdayAt) { lastYesterday = departure; lastYesterdayAt = at; }
}
const completedToday = Boolean(lastToday && lastTodayAt + minute <= now);
const beforeFirstToday = firstTodayAt > now;
// Overnight arrivals belong to their original service day. Do not close the
// stop while its final bus is still approaching or within the boarding grace.
let last = completedToday ? lastToday : beforeFirstToday && lastYesterday && lastYesterdayAt + minute <= now ? lastYesterday : undefined;
let continuation = false;
// Some carriers store their 00:xx departures as the new calendar day. Extend
// the previous closing block only through the adjacent two-hour tail, and
// require the first long break directly after it. Continuous overnight or
// daytime service cannot become a false night because of a later long gap.
if (!completedToday && lastYesterday && firstTodayAt >= lastYesterdayAt) {
let tail: DisplayDeparture | undefined, tailAt = -Infinity;
for (const item of todays) if (item.at <= lastYesterdayAt + 120 * minute && item.at > tailAt) { tail = item.departure; tailAt = item.at; }
let nextAt = Infinity;
for (const item of todays) if (item.at > tailAt) nextAt = Math.min(nextAt, item.at);
if (tail && Number.isFinite(nextAt) && nextAt - tailAt > 120 * minute && tailAt + minute <= now && now < nextAt) { last = tail; continuation = true; }
}
if (!last) return inactive;
if (!first || (!continuation && day(first) <= day(last))) return inactive;
// Classify the whole overnight break, not the time remaining at startup.
// A reload during the same break must stay in night mode until wake-up.
const gap = firstAt - Date.parse(last.scheduledAt);
if (gap <= 120 * minute || firstAt - now <= settings.nightWakeMinutes * minute) return inactive;
return { active: true, first, completedToday };
}

View File

@@ -10,6 +10,7 @@ export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds:
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
export function readSettings(): Settings {
try {
@@ -22,6 +23,7 @@ export function readSettings(): Settings {
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',
nightMode: saved.nightMode === 'off' ? 'off' : 'auto', nightWakeMinutes: saved.nightWakeMinutes === 45 ? 45 : 60,
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 };

View File

@@ -12,12 +12,17 @@ export function isTimetable(value, cityId, stopId) {
&& Array.isArray(value.calendarYears) && value.calendarYears.every(Number.isInteger)
&& Array.isArray(value.holidays) && value.holidays.every(validDate)
&& Array.isArray(value.routes) && value.routes.every(route => Number.isSafeInteger(route.id) && route.id > 0 && typeof route.number === 'string'
&& (route.stops === undefined || Array.isArray(route.stops) && route.stops.every(stop => Number.isSafeInteger(stop.id) && stop.id > 0
&& typeof stop.name === 'string' && stop.name.trim() && Number.isSafeInteger(stop.sequence) && stop.sequence >= 0
&& (stop.offsetMinutes === null || typeof stop.offsetMinutes === 'number' && Number.isFinite(stop.offsetMinutes) && stop.offsetMinutes >= 0 && stop.offsetMinutes < 1440))
&& new Set(route.stops.map(stop => stop.id)).size === route.stops.length && new Set(route.stops.map(stop => stop.sequence)).size === route.stops.length)
&& typeof route.name === 'string' && Array.isArray(route.schedules) && route.schedules.every(schedule =>
['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.dayType) && (!schedule.validFrom || validDate(schedule.validFrom))
&& (!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.endStopId == null || Number.isSafeInteger(trip.endStopId) && trip.endStopId > 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)))));
}
@@ -45,7 +50,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,
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
}
}

View File

@@ -101,33 +101,82 @@ 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; }
/* A moving line and one large stop, inspired by over-door transit displays. */
.service-info { container-name: journey-card; overflow: hidden; }
.journey-route { display: flex; align-items: center; gap: 18px; min-width: 0; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
.journey-route .route-badge { flex-shrink: 0; width: 80px; height: 62px; border-radius: 18px; font-size: 36px; }
.journey-trip { min-width: 0; flex: 1; display: flex; align-items: center; gap: 18px; }
.journey-route-name { min-width: 0; flex: 1; }
.service-info .journey-trip .destination-heading { margin: 0; font-size: clamp(22px, 3.8cqw, 36px); line-height: 1.2; overflow-wrap: anywhere; }
.journey-trip time { display: flex; gap: 8px; align-items: baseline; color: var(--accent); font-size: clamp(22px, 4.5cqw, 40px); font-weight: 600; }
.journey-trip .journey-day { color: var(--secondary); font-size: 17px; font-weight: 400; }
.service-info .journey-empty { margin-block: auto; padding: 20px 0; text-align: center; font-size: 22px; }
.journey-metro { --station-width: clamp(180px, 22cqw, 260px); --focused-width: min(clamp(300px, 60cqw, 560px), calc(100cqw - 48px)); flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column; padding-top: clamp(16px, 4cqh, 32px); }
.journey-tour-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: var(--secondary); font-size: clamp(17px, 3.3cqw, 24px); line-height: 1.3; }
.journey-order { color: var(--accent); font-size: .85em; white-space: nowrap; }
.journey-map-space { flex: 1 1 0; min-height: 0; display: flex; align-items: center; margin: 18px -8px 0; }
.journey-rail-window { width: 100%; height: min(100%, 320px); position: relative; overflow: hidden; }
.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; }
.journey-rail { display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; transform: translateX(calc(-1 * var(--journey-focus) * var(--station-width))); transition: transform 900ms cubic-bezier(.22,.72,.18,1); }
.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: 34px minmax(0, 1fr) 36px; gap: 10px; padding-right: 28px; transition: flex-basis 900ms cubic-bezier(.22,.72,.18,1); }
.journey-station[data-focused=true] { flex-basis: var(--focused-width); }
.journey-station::after { content: ''; position: absolute; bottom: 15.5px; left: 0; width: 100%; height: 5px; background: var(--accent); transition: opacity 700ms ease; }
.journey-station:last-child::after { width: calc(var(--station-width) - 36px); }
.journey-metro[data-more=false] .journey-station:last-child::after { display: none; }
.journey-station[data-before=true]::after { opacity: .25; }
.journey-station-name { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 8px; transition: opacity 500ms ease; }
.journey-station[data-before=true] .journey-station-name, .journey-station[data-before=true] .journey-duration { opacity: 0; }
.service-info .journey-station-name .destination-heading { flex-shrink: 0; margin: 0; font-size: calc(clamp(22px, 4cqw, 32px) * var(--reading-scale, 1)); line-height: 1.13; font-weight: 600; letter-spacing: -.035em; overflow-wrap: anywhere; transition: color 600ms ease; }
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
.journey-station[data-focused=true] .destination-current { transform-origin: left bottom; animation: journey-name-in 900ms cubic-bezier(.22,.72,.18,1) both; }
@keyframes journey-name-in { from { opacity: 0; transform: translateX(20px) scale(.84); } to { opacity: 1; transform: translateX(0) scale(1); } }
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(18px, 3.4cqw, 26px); font-weight: 550; white-space: nowrap; transition: color 600ms ease, opacity 500ms ease; }
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
.journey-duration small { font-size: .72em; font-weight: 500; }
.journey-station[data-focused=true] .journey-duration { color: var(--accent); font-size: clamp(24px, 4.5cqw, 36px); }
.journey-node { align-self: center; display: block; width: 18px; height: 18px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -9px; z-index: 1; transition: background 600ms ease, box-shadow 600ms ease; }
.journey-station[data-focused=true] .journey-node { background: var(--accent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 18%, transparent); }
.journey-station[data-before=true] .journey-node { border-color: var(--outline); }
.journey-continuation { flex: 0 0 var(--station-width); min-width: 0; display: flex; align-items: center; position: relative; padding: 34px 20px 36px 0; }
.journey-continuation-label { color: var(--muted); font-size: clamp(20px, 4cqw, 28px); line-height: 1.2; max-width: 180px; }
.journey-continuation-line { position: absolute; bottom: 15.5px; left: 0; right: 24px; border-top: 5px dashed var(--accent); opacity: .5; }
.journey-direction-arrow { position: absolute; left: 1px; bottom: 1px; color: var(--accent); font-size: 42px; line-height: 36px; font-weight: 700; }
.journey-caption { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; color: var(--muted); font-size: 16px; line-height: 1.3; padding-top: 16px; }
.journey-page { color: var(--accent); margin-left: auto; }
.journey-metro[data-calm=true] .journey-rail { transition: transform 300ms ease-out; }
.journey-metro[data-calm=true] .journey-station { transition: flex-basis 300ms ease-out; }
.journey-metro[data-calm=true] .destination-heading { transition: color 250ms ease-out; }
.journey-metro[data-calm=true] .destination-current { animation: none; }
.journey-metro[data-loop=true] .journey-rail { transition: none; animation: journey-overview-reset 600ms ease-out both; }
.journey-metro[data-loop=true] .journey-station { transition: none; }
@keyframes journey-overview-reset { from { opacity: 0; } to { opacity: 1; } }
@container journey-card (max-width: 380px) {
.journey-trip { display: block; }
.journey-trip time { margin-top: 7px; }
.journey-metro { --station-width: 140px; }
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
.journey-station { padding-right: 22px; }
.journey-tour-heading { font-size: 17px; }
.journey-caption { font-size: 14px; gap: 4px 10px; }
}
@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 journey-card (max-height: 380px) {
.journey-route { gap: 12px; padding-bottom: 12px; }
.journey-route .route-badge { width: 72px; height: 48px; border-radius: 14px; font-size: 30px; }
.service-info .journey-trip .destination-heading { font-size: 24px; }
.journey-trip time { font-size: 24px; }
.journey-metro { padding-top: 12px; }
.journey-map-space { margin-top: 10px; }
.journey-station { grid-template-rows: 26px minmax(0, 1fr) 28px; gap: 6px; }
.journey-station-name { padding-bottom: 4px; }
.journey-station::after { bottom: 12px; height: 4px; }
.journey-direction-arrow { bottom: 0; font-size: 36px; line-height: 28px; }
.journey-node { width: 16px; height: 16px; margin-left: -8px; border-width: 3px; }
.journey-continuation-line { bottom: 12px; border-top-width: 4px; }
.journey-caption { font-size: 14px; padding-top: 10px; }
}
@container service-card (min-width: 600px) and (max-height: 380px) {
.service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; }
@media (prefers-reduced-motion: reduce) {
.journey-rail, .service-info .journey-station .destination-heading, .journey-station, .journey-station-name, .journey-duration, .journey-station::after, .journey-node { transition: none; }
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail { animation: none; }
}
.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; }
@@ -518,3 +567,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
/* Device onboarding keeps the same black/lime typography as the board. */
.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}}
/* Quiet schedule-based night screen. The saved daytime theme remains intact. */
.board[data-night=true] .cycle-progress{opacity:0}.night-screen{height:100%;min-height:0;display:flex;flex-direction:column;gap:clamp(14px,2.4vh,30px);padding:clamp(14px,2.2vh,28px) 0;container-type:size}.night-intro{flex-shrink:0}.night-intro .eyebrow{display:flex;align-items:center;gap:10px;color:var(--accent)}.night-intro h2{font-size:clamp(26px,3.3vw,54px);letter-spacing:-.05em;font-weight:650;margin:12px 0 0;line-height:1.15;max-width:1000px}.night-content{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);align-items:center}.night-service{min-width:0;display:flex;flex-direction:column;gap:clamp(10px,2cqh,20px)}.night-service-label{font-size:clamp(20px,2vw,32px);color:var(--muted)}.night-service-time{font-size:clamp(64px,min(13vw,28cqh),180px);font-weight:650;line-height:1;color:var(--accent);letter-spacing:-.055em;display:block}.night-route{display:flex;align-items:center;gap:clamp(14px,2vw,26px);min-width:0}.night-route .route-badge{width:clamp(72px,8vw,130px);height:clamp(58px,10cqh,100px);border-radius:22px;flex-shrink:0;font-size:clamp(28px,4vw,60px)}.night-route h3{font-size:clamp(24px,3vw,46px);font-weight:600;line-height:1.2;letter-spacing:-.045em;overflow-wrap:anywhere;margin:0}.night-alerts{flex:0 1 30%;min-height:100px;max-height:200px;overflow:auto;position:relative;scrollbar-width:none}.night-alerts .notice{grid-template-columns:minmax(0,1fr);margin:0;padding:16px 20px;border-radius:18px}.night-alerts .notice h3{font-size:clamp(20px,1.8vw,28px)}.night-alerts .notice p{font-size:clamp(18px,1.5vw,24px);line-height:1.35}.night-alerts .notice-routes{display:flex;gap:8px;margin:8px 0}.night-alerts .notice-routes .route-badge{width:62px;height:38px;border-radius:10px;font-size:22px}.night-alerts .notice-graphic{display:none}.night-alert-page{display:block;text-align:right;color:var(--muted);font-size:14px;padding:6px}.board[data-night=true] .numeric-old,.board[data-night=true] .numeric-next,.board[data-night=true] .numeric-previous{animation:none!important}.board[data-night=true] .numeric-current{animation:none!important}@media(max-width:700px){.night-screen{gap:16px;padding:18px 0}.night-intro h2{font-size:clamp(26px,6.7vw,36px)}.night-content{grid-template-columns:1fr;gap:18px;align-content:center}.night-service{gap:12px}.night-service-time{font-size:clamp(68px,min(22vw,24cqh),115px)}.night-route h3{font-size:clamp(23px,5.7vw,32px)}.night-alerts{max-height:150px;flex-basis:28%}.night-alerts .notice{padding:14px}.night-alerts .notice-meta{flex-wrap:wrap}.night-alerts .notice-period{font-size:13px}.night-alerts .notice p{font-size:18px}}@media(max-height:620px) and (min-width:701px){.night-screen{gap:12px;padding:12px 0}.night-intro h2{font-size:clamp(25px,3vw,36px);margin-top:8px}.night-service-time{font-size:clamp(64px,28cqh,120px)}.night-route h3{font-size:clamp(23px,3vw,36px)}.night-route .route-badge{height:56px;width:80px}.night-alerts{min-height:90px;max-height:110px;flex-basis:28%}.night-service-label{font-size:22px}}

View File

@@ -1,9 +1,10 @@
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;
serviceDate?: string; originStopId?: number; originTime?: string; endStopId?: number;
};
export type Route = { id: number; number: string; name: string; type: string };
export type RouteStop = { id: number; name: string; sequence: number; offsetMinutes: number | 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 };
@@ -14,10 +15,11 @@ export type Settings = { stopId: number; demo: boolean; mainSeconds: number; inf
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';
nightMode: 'off' | 'auto'; nightWakeMinutes: 45 | 60;
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[]; originStopId?: number; originTime?: string }[];
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { stops?: RouteStop[]; schedules: {
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string; endStopId?: number }[];
}[] })[]; 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 }[] };

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Config, Settings } from './types';
import { isManagedState, MANAGED_KEY } from './lib/device.mjs';
import { hydrateManagedState, isManagedState, MANAGED_KEY } from './lib/device.mjs';
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } }
function cached(): ManagedState | null { try { const value = hydrateManagedState(JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null')); return isManagedState(value) ? value : null; } catch { return null; } }
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
export function useDeviceControl() {
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
@@ -38,6 +38,7 @@ export function useDeviceControl() {
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
if (value.state === 'paired' && !value.config) value = await call('status');
if (value.state === 'paired') {
value = hydrateManagedState(value);
if (!isManagedState(value)) throw new Error('invalid_settings');
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }