feat: add passenger information and settings components

- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information.
- Added `PassengerSettings.tsx` for managing user-defined important places and fare settings.
- Implemented validation functions for passenger settings in `passenger-settings.mjs`.
- Created utility functions for transliterating stop names in `transliteration.ts`.
- Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities.
- Established a mock server for testing passenger-related features in `passenger-server.mjs`.
- Added comprehensive tests for passenger guide logic and transliteration functionality.
This commit is contained in:
2026-10-12 20:30:49 +03:00
parent 823ab14367
commit e239de45dd
34 changed files with 687 additions and 68 deletions

View File

@@ -28,6 +28,10 @@ import type { ManagedState } from './useDeviceControl';
import { DeviceSetup, ManagedPanel } from './DeviceSetup';
import { nightWindow } from './lib/night';
import { NightScreen } from './NightScreen';
import { PlacesCard, FareCard, LastTripNotice } from './PassengerInfo';
import { passengerGuide } from './lib/passenger-guide';
import type { PassengerGuide } from './lib/passenger-guide';
import { demoGuideTimetable } from './lib/demo';
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' });
@@ -51,8 +55,9 @@ function DepartureStatus({ departure, now, large = false }: { departure: Display
</div>;
}
export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: {
export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings, lastTripId }: {
data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
lastTripId?: string | null;
}) {
const first = firstAvailable(departures);
const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
@@ -88,6 +93,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
{waiting ? <div className="long-wait-time"><span>{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}</span><ScheduledTime departure={first} now={now} showDay={false} />{first.status === 'notice' && <span className="departure-change">Есть изменения</span>}</div> : <DepartureStatus departure={first} now={now} large />}
</div> : <div className="empty-departures" data-cancelled={departures.length > 0}><BusFront size={46} /><h2>{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
<p>{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
{first && first.id === lastTripId && <LastTripNotice number={first.routeNumber} />}
<div className="next-heading"><span className="eyebrow">{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count}</span> : <ArrowDown size={22} />}</div>
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
{visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}>
@@ -104,17 +110,19 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
</div>;
}
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 }) {
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages, guide, onPlacePages }: { 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;
guide: PassengerGuide; onPlacePages: (pages: number) => void }) {
const panel = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
const blocks = informationBlocks(settings);
const blocks = informationBlocks(settings, { places: !!guide.places.length, fare: !!guide.fare });
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]);
useEffect(() => { if (alerts.length || !settings.infoPlacesEnabled || compact && activeBlock !== 'places') onPlacePages(1); }, [alerts.length, settings.infoPlacesEnabled, compact, activeBlock, onPlacePages]);
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'); });
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(blocks.length > 3 || width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
observer.observe(grid.current);
return () => observer.disconnect();
}, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
@@ -128,15 +136,18 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
}, 3500);
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length}
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length} data-many-blocks={blocks.length > 3}
style={{ '--info-columns': blocks.length, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Информация'}</h2>
{!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 > 0 || blocks.length > 1) && <div className="info-intro" data-tabs-only={!alerts.length}>
{alerts.length > 0 && <h2>Изменения движения</h2>}
{!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' && <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 === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} hintEnabled={settings.weatherHintEnabled} />}
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
{block.id === 'places' && <PlacesCard trips={guide.places} now={now} paused={paused} settings={settings} active={!compact || activeBlock === 'places'} onPages={onPlacePages} />}
{block.id === 'fare' && guide.fare && <FareCard fare={guide.fare} />}
</section>)}
</div>}
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
@@ -186,6 +197,14 @@ 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 minuteKey = Math.floor(now / 60000);
const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]);
// Work once per minute or when the nearest trip/config/data changes, not on
// every clock digit: full-route searches should not tax the TV's CPU.
const guide = useMemo(() => passengerGuide(settings, settings.demo ? guideDemo : timetable.data, liveData, now, config.cityId, reliableAlerts),
[settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
const [placePages, setPlacePages] = useState(1);
const availablePlaces = !!guide.places.length, availableFare = !!guide.fare;
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);
@@ -193,8 +212,9 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
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(() => 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 scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent, { places: availablePlaces, fare: availableFare }), [settings, hasAlerts, urgent, night.active, availablePlaces, availableFare]);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0,
!hasAlerts && availablePlaces ? placePages * (reading.calm ? 10 : 7) : 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 || night.active });
@@ -278,12 +298,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
{management?.failed && <div className="managed-offline" role="status">Нет связи с управлением · сохранённые настройки</div>}
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{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 === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} lastTripId={guide.lastTripId} />}
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={value => { setInfoPage(value); setStartedAt(Date.now()); }} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages} onPlacePages={setPlacePages} guide={guide}
journey={{ departure: first, journey, loading: timetable.loading || loading, 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>
{!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>}
{!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} />{guide.lastTripId === first.id && <LastTripNotice number={first.routeNumber} compact />}</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} ${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>

View File

@@ -1,17 +1,16 @@
import { memo, useEffect, useLayoutEffect, useRef, 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 { transliterateStopName } from './lib/transliteration';
import type { OnwardJourney } from './lib/journey';
import type { Departure, Settings } from './types';
import type { CSSProperties } from 'react';
// Fixed-size cells move on the compositor. Both text sizes are fitted once,
// so moving to the next stop never resizes a cell or remounts its heading.
const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: OnwardJourney; focus: number }) {
const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) {
const rail = useRef<HTMLDivElement>(null);
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
useLayoutEffect(() => {
@@ -38,27 +37,32 @@ const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: Onw
'--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
<span className="journey-line" aria-hidden="true" style={{ transform: `scaleX(${scale})` }} />
<ol className="journey-rail" aria-label="Следующие остановки после посадки">
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={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">
<div className="journey-name-layer journey-name-context" aria-hidden={index === focus}><FittedDestination text={stop.name} /></div>
<div className="journey-name-layer journey-name-focus" aria-hidden={index !== focus}><FittedDestination text={stop.name} /></div>
</div>
<span className="journey-node" aria-hidden="true" />
</li>)}
{journey.stops.map((stop, index) => {
const latinName = translit ? transliterateStopName(stop.name) : stop.name;
return <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={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">
{(['context', 'focus'] as const).map(size => <div key={size} className={`journey-name-layer journey-name-${size}`} aria-hidden={size === 'focus' ? index !== focus : index === focus}>
{latinName !== stop.name && <span className="journey-transliteration" lang="ru-Latn" aria-hidden="true">{latinName}</span>}
<FittedDestination key={translit ? 'bilingual' : 'native'} text={stop.name} />
</div>)}
</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>
</div>;
}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more
}, (previous, next) => previous.focus === next.focus && previous.translit === next.translit && previous.journey.more === next.journey.more
&& previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => {
const other = next.journey.stops[index];
return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes;
}));
export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
export function NextStops({ departure, journey, loading, paused, settings, active, onPages }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; paused: boolean; settings: Settings;
active: boolean; onPages: (pages: number) => void;
}) {
const [page, setPage] = useState(0);
@@ -73,14 +77,12 @@ export function NextStops({ departure, journey, loading, now, paused, settings,
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>
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-active={active} data-calm={reading.calm} 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"><JourneyRail journey={journey} focus={focus} /></div>
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} translit={settings.journeyTranslitEnabled} /></div>
</div>
<div className="journey-caption"><span className="journey-caption-full">Время пути по расписанию</span><span className="journey-caption-compact">После посадки · по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
</>}

54
src/PassengerInfo.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { NumericText } from './NumericText';
import { Clock, Moon } from './Icons';
import { localDate, shiftDate } from './lib/timetable.mjs';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import type { PlaceTrip } from './lib/passenger-guide';
import type { FareTerms, Settings } from './types';
import type { CSSProperties } from 'react';
const format = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
const checked = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' });
function TripDay({ at, now }: { at: string; now: number }) {
const day = localDate(Date.parse(at)), today = localDate(now);
return day <= today ? null : <small>{day === shiftDate(today, 1) ? 'завтра' : day === shiftDate(today, 2) ? 'послезавтра' : day.slice(5).split('-').reverse().join('.')}</small>;
}
export function LastTripNotice({ number, compact = false }: { number: string; compact?: boolean }) {
return <div className={`last-trip-notice ${compact ? 'last-trip-compact' : ''}`} role="status"><Moon size={18} /><span>Последний №{number} перед ночным перерывом <small>· по расписанию</small></span></div>;
}
export function PlacesCard({ trips, now, paused, active, settings, onPages }: { trips: PlaceTrip[]; now: number; paused: boolean; active: boolean; settings: Settings; onPages: (pages: number) => void }) {
const body = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0), [page, setPage] = useState(0);
const reading = readability(settings);
const size = Math.max(1, Math.min(3, Math.floor(height / (110 * reading.scale))));
const pages = Math.max(1, Math.ceil(trips.length / size));
const visible = trips.slice((page % pages) * size, (page % pages + 1) * size);
useLayoutEffect(() => {
if (!body.current) return;
const observer = new ResizeObserver(entries => setHeight(entries[0].contentRect.height)); observer.observe(body.current);
return () => observer.disconnect();
}, []);
useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
useEffect(() => { setPage(0); }, [settings.places]);
useEffect(() => {
if (paused || !active || pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
return () => clearInterval(timer);
}, [paused, active, pages, reading.calm]);
return <><h3>До важных мест</h3><div className="places-body" ref={body} style={{ '--place-rows': visible.length } as CSSProperties}>
{visible.map((trip, index) => <article className="place-trip" key={`place-${index}`}>
<div className="place-copy"><FittedDestination text={trip.place.name} />
{trip.departure ? <div className="place-details"><RouteBadge number={trip.departure.routeNumber} filled /><time dateTime={trip.departure.scheduledAt}><NumericText value={trip.departure.scheduledTime} /><TripDay at={trip.departure.scheduledAt} now={now} /></time>
{trip.minutes !== null && <span className="place-duration" aria-label={`В пути ${trip.minutes} мин по расписанию`}><Clock size={18} />{trip.minutes === 0 ? '<1 мин' : durationParts(trip.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /> {part.unit}</span>)}</span>}</div>
: <p className="place-unavailable">Ближайший прямой рейс не найден</p>}
</div>
{trip.arrivalAt && <div className="place-arrival"><span>Прибытие</span><time dateTime={trip.arrivalAt}><NumericText value={format.format(Date.parse(trip.arrivalAt))} /><TripDay at={trip.arrivalAt} now={now} /></time></div>}
</article>)}
</div><div className="passenger-caption"><span>Прямые рейсы · по расписанию</span>{pages > 1 && <span><NumericText value={`${page % pages + 1}`} /> / {pages}</span>}</div></>;
}
export function FareCard({ fare }: { fare: FareTerms }) {
return <><h3>Оплата поездки</h3><div className="fare-body"><span className="fare-label">Для ближайшего маршрута</span><div className="fare-price"><FittedDestination text={fare.price} as="h2" /></div><div className="fare-payment"><FittedDestination text={fare.payment} /></div></div><div className="passenger-caption">Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}</div></>;
}

40
src/PassengerSettings.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { localDate } from './lib/timetable.mjs';
import { demoPlaces } from './lib/demo';
import type { Settings, Stop, Route } from './types';
export function PassengerSettings({ settings, stops, routes, cityId, update }: { settings: Settings; stops: Stop[]; routes: Route[]; cityId: number; update: <K extends keyof Settings>(key: K, value: Settings[K]) => void }) {
const targets = settings.demo ? demoPlaces.map(stop => ({ ...stop, direction: 'демо' })) : stops;
return <div className="passenger-settings">
<h4>Важные места</h4><p className="form-hint">До трёх мест. Выберите конкретную остановку назначения и короткое название для пассажиров. Табло найдёт ближайший прямой рейс с учётом его направления и конечной.</p>
{settings.places.map((place, index) => <fieldset className="passenger-editor" key={index}><legend>Место {index + 1}</legend>
<label>Остановка назначения<select aria-label={`Остановка места ${index + 1}`} required value={place.stopId || ''} onChange={e => { const stop = targets.find(s => s.id === Number(e.target.value)); update('places', settings.places.map((item, i) => i === index ? { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' } : item)); }}>
<option value="">Выберите остановку</option>{!targets.some(stop => stop.id === place.stopId) && place.stopId > 0 && <option value={place.stopId}>{place.name} · сохранённая остановка</option>}
{targets.filter(stop => stop.id !== settings.stopId).map(stop => <option key={stop.id} value={stop.id} disabled={settings.places.some((p, i) => i !== index && p.stopId === stop.id)}>{stop.name}{stop.direction ? ` · ${stop.direction}` : ''}</option>)}
</select></label><label>Название на табло<input aria-label={`Название места ${index + 1}`} required maxLength={48} value={place.name} onChange={e => update('places', settings.places.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} /></label>
<button type="button" className="text-button" onClick={() => update('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}</button>
</fieldset>)}
<button type="button" className="text-button" disabled={settings.places.length >= 3 || !targets.length} onClick={() => update('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место</button>
<h4>Оплата поездки</h4><p className="form-hint">Основные тарифы по типу транспорта. Укажите подтверждённую цену с валютой, способы оплаты и дату проверки. Без подходящего тарифа карточка скрыта.</p>
{(['bus', 'minibus'] as const).map(type => {
const rule = settings.fareByType.find(rule => rule.type === type && rule.cityId === cityId);
const label = type === 'bus' ? 'автобуса' : 'маршрутки';
const edit = (key: 'price' | 'payment' | 'checkedAt', value: string) => update('fareByType', settings.fareByType.map(item => item.type === type && item.cityId === cityId ? { ...item, [key]: value } : item));
return <fieldset className="passenger-editor" key={type}><legend>Тариф {label}</legend>
<label className="switch-row"><span>Использовать тариф {label}</span><input type="checkbox" checked={!!rule} onChange={e => update('fareByType', [...settings.fareByType.filter(item => item.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: localDate(Date.now()) }] : [])])} /></label>
{rule && <><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость ${label}`} required maxLength={40} value={rule.price} onChange={e => edit('price', e.target.value)} /></label><label>Проверено<input aria-label={`Дата проверки ${label}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => edit('checkedAt', e.target.value)} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты ${label}`} required maxLength={100} value={rule.payment} onChange={e => edit('payment', e.target.value)} /></label></>}
</fieldset>;
})}
<h4>Исключения для маршрутов</h4><p className="form-hint">Тариф отдельного маршрута заменяет основной тариф его типа транспорта.</p>
{settings.fareRules.map((rule, index) => <fieldset className="passenger-editor" key={index}><legend>Тариф {index + 1}</legend>
<label>Маршрут<select aria-label={`Маршрут тарифа ${index + 1}`} required value={rule.routeId || ''} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, routeId: Number(e.target.value) } : item))}>
<option value="">Выберите маршрут</option>{!routes.some(route => route.id === rule.routeId) && rule.routeId > 0 && <option value={rule.routeId}>Маршрут {rule.routeId} · сохранённый тариф</option>}
{routes.map(route => <option key={route.id} value={route.id} disabled={settings.fareRules.some((r, i) => i !== index && r.routeId === route.id)}>№{route.number} · {route.name}</option>)}
</select></label><div className="form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость тарифа ${index + 1}`} required maxLength={40} placeholder="Цена с валютой" value={rule.price} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, price: e.target.value } : item))} /></label>
<label>Проверено<input aria-label={`Дата проверки тарифа ${index + 1}`} required type="date" max={localDate(Date.now())} value={rule.checkedAt} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, checkedAt: e.target.value } : item))} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты тарифа ${index + 1}`} required maxLength={100} value={rule.payment} onChange={e => update('fareRules', settings.fareRules.map((item, i) => i === index ? { ...item, payment: e.target.value } : item))} /></label>
<button type="button" className="text-button" onClick={() => update('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}</button>
</fieldset>)}
<button type="button" className="text-button" disabled={settings.fareRules.length >= 32 || !routes.length} onClick={() => update('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: localDate(Date.now()) }])}>Добавить тариф</button>
</div>;
}

View File

@@ -3,6 +3,7 @@ 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 { PassengerSettings } from './PassengerSettings';
import type { BoardData, Config, Settings, Stop } from './types';
const sections = ['Остановка', 'Оформление', 'Экраны', 'Реклама'];
@@ -100,6 +101,10 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
<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>)}
<label className="switch-row"><span>Транслит названий остановок<small>Латиницей над основным названием в схеме ближайшего рейса</small></span><input type="checkbox" disabled={!draft.infoServiceEnabled} checked={draft.journeyTranslitEnabled} onChange={event => update('journeyTranslitEnabled', event.target.checked)} /></label>
<label className="switch-row"><span>Напоминание о последнем рейсе<small>За 30 минут до последнего рейса направления перед ночным перерывом</small></span><input type="checkbox" checked={draft.lastTripNoticeEnabled} onChange={event => update('lastTripNoticeEnabled', event.target.checked)} /></label>
<label className="switch-row"><span>Вероятность осадков<small>Короткая подсказка в погодном блоке</small></span><input type="checkbox" checked={draft.weatherHintEnabled} onChange={event => update('weatherHintEnabled', event.target.checked)} /></label>
<PassengerSettings settings={draft} stops={stops} routes={data?.routes || []} cityId={config.cityId} update={update} />
{!informationBlocks(draft).length && <p className="form-hint">Экран информации появится только при предупреждениях о движении.</p>}
</section>
<section hidden={page !== 3} data-settings-section="3" id="settings-section-3"><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>

View File

@@ -2,6 +2,7 @@ import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
import { NumericText } from './NumericText';
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
import type { Weather } from './types';
import { precipitationHint } from './lib/weather-hint';
const icons = { cloud: Cloud, moon: Moon, rain: Rain, snow: Snow, storm: Storm, sun: Sun };
const hourFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
@@ -17,12 +18,14 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
<WeatherIcon code={data.code} isDay={data.isDay} size={25} /><strong><NumericText value={degrees(data.temperature)} /></strong><span>{weatherCondition(data.code, data.isDay).label}</span>{failed && <small>сохранено</small>}
</div>;
}
export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
export function WeatherCard({ data, now, loading, failed, demo, hintEnabled = true }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean; hintEnabled?: boolean }) {
const available = weatherAvailable(data, now);
if (!available || !data) return <><h3>Погода</h3><p className="weather-empty">{demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}</p></>;
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
const probability = hintEnabled ? precipitationHint(data, now) : null;
return <><h3>Погода</h3><div className="weather-body" data-forecast={!!hours.length}><div className="weather-now"><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={72} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p></div>
{!!hours.length && <div className="weather-hours" aria-label="Прогноз на следующие три часа">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={48} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}</div>
{probability !== null && <div className="weather-hint"><Rain size={24} /><span>Вероятность осадков в ближайший час</span><strong><NumericText value={`${probability}%`} /></strong></div>}
<div className="weather-attribution">{failed && <span>Сохранённый прогноз</span>}<a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
}

View File

@@ -1,4 +1,4 @@
import type { BoardData } from '../types';
import type { BoardData, Timetable } from '../types';
import { minskDate } from './board.mjs';
import type { Departure } from '../types';
import type { OnwardJourney } from './journey';
@@ -8,6 +8,19 @@ export function demoJourney(departure: Departure | undefined): OnwardJourney | n
return { stops: ['Площадь Свободы', 'Дом торговли', 'Поликлиника', departure.destination].map((name, index) => ({ id: 1000 + index, name, minutes: [3, 6, 9, 18][index] })), more: 0 };
}
export const demoPlaces = [{ id: 1000, name: 'Площадь Свободы' }, { id: 1001, name: 'Дом торговли' }, { id: 1002, name: 'Поликлиника' }];
export function demoGuideTimetable(data: BoardData | null, stopId: number): Timetable | null {
if (!data || data.mode !== 'demo') return null;
return { version: 1, cityId: data.cityId, cityName: data.cityName, stop: { ...data.stop, id: stopId },
updatedAt: data.updatedAt, expiresAt: new Date(Date.parse(data.updatedAt) + 86400000).toISOString(), holidays: [], calendarYears: [Number(data.serviceDate.slice(0, 4))],
routes: data.routes.map(route => ({ ...route, stops: [{ id: stopId, name: data.stop.name, sequence: 0, offsetMinutes: 0 },
...demoPlaces.map((stop, i) => ({ ...stop, sequence: i + 1, offsetMinutes: (i + 1) * 3 })), { id: 1003, name: route.name, sequence: 4, offsetMinutes: 18 }],
schedules: (['weekday', 'saturday', 'sunday'] as const).map(dayType => ({ dayType, validFrom: null, validUntil: null,
trips: data.departures.filter(trip => trip.routeId === route.id).map(trip => ({ arrivalSeconds: Number(trip.scheduledTime.slice(0, 2)) * 3600 + Number(trip.scheduledTime.slice(3)) * 60,
originStopId: stopId, endStopId: 1003, destination: trip.destination, note: '', daysOfWeek: [], exceptDaysOfWeek: [] })) })) })),
};
}
export function createDemo(now = Date.now()): BoardData {
const routes = [
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },

View File

@@ -1,8 +1,9 @@
import { colorSchemes, lightColorSchemes } from './presentation.mjs';
import { passengerDefaults, validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.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 } } };
return { ...value, device: { ...value.device, settings: { ...passengerDefaults, fareByType: value.device.cityId === 1 ? confirmedPolotskFares : [], nightMode: 'auto', nightWakeMinutes: 60, journeyTranslitEnabled: false, ...value.device.settings } } };
}
export function isManagedState(value) {
value = hydrateManagedState(value);
@@ -13,6 +14,7 @@ export function isManagedState(value) {
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;
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'journeyTranslitEnabled', 'infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false;
if (!validPlaces(s.places) || !validFareRules(s.fareRules) || !validFareByType(s.fareByType) || s.fareByType.some(rule => rule.cityId !== device.cityId)) 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,16 +1,20 @@
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-код расписания' },
{ id: 'places', setting: 'infoPlacesEnabled', name: 'Места', label: 'До важных мест' },
{ id: 'fare', setting: 'infoFareEnabled', name: 'Оплата', label: 'Оплата поездки' },
] as const;
export function informationBlocks(settings: Settings) {
return informationOptions.filter(block => settings[block.setting]);
export type InformationAvailable = { places: boolean; fare: boolean };
export function informationBlocks(settings: Settings, available?: InformationAvailable) {
return informationOptions.filter(block => settings[block.setting] && (block.id !== 'places' || (available?.places ?? settings.places.length > 0))
&& (block.id !== 'fare' || (available?.fare ?? settings.fareRules.length + settings.fareByType.length > 0)));
}
export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean): Scene[] {
return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean, available?: InformationAvailable): Scene[] {
return ['departures', ...(hasAlerts || informationBlocks(settings, available).length ? ['info'] : []),
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
}

View File

@@ -0,0 +1,68 @@
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './timetable.mjs';
import { firstAvailable, displayDepartures } from './departures.ts';
import { activeAlerts, upcomingDepartures } from './board.mjs';
import { nightWindow } from './night.ts';
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
import type { DisplayDeparture } from './departures';
type Segment = { stops: NonNullable<Timetable['routes'][number]['stops']>; current: number; end: number; direction: number };
// The actual trip endpoints matter: a route's full topology is not a promise
// that this particular bus reaches every stop, or travels in both directions.
function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null {
const stops = bundle.routes.find(route => route.id === trip.routeId)?.stops?.slice().sort((a, b) => a.sequence - b.sequence);
if (!stops?.length || !trip.originStopId || !trip.endStopId) return null;
const start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
const current = stops.findIndex(s => s.id === stopId);
if (start < 0 || end < 0 || current < Math.min(start, end) || current > Math.max(start, end) || start === end) return null;
return { stops, current, end, direction: Math.sign(end - start) };
}
export type PlaceTrip = { place: Place; departure: DisplayDeparture | null; minutes: number | null; arrivalAt: string | null };
export type PassengerGuide = { places: PlaceTrip[]; fare: FareTerms | null; lastTripId: string | null };
export function passengerGuide(settings: Settings, bundle: Timetable | null, data: BoardData | null, now: number, cityId: number, reliableAlerts: boolean): PassengerGuide {
const result: PassengerGuide = { places: [], fare: null, lastTripId: null };
if (data && data.mode !== 'demo' && (data.cityId !== cityId || data.stop.id !== settings.stopId)) data = null;
const available = Boolean(bundle && bundle.cityId === cityId && bundle.stop.id === settings.stopId && timetableAvailable(bundle, now));
const date = localDate(now);
const raw = available ? timetableDepartures(bundle, date) as Departure[] : upcomingDepartures(data, now, settings.routeFilter) as Departure[];
const alerts = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const all = displayDepartures(raw, alerts, settings.stopId, now, reliableAlerts);
const upcoming = all.filter(trip => Date.parse(trip.scheduledAt) + 60000 > now && (!settings.routeFilter || String(trip.routeId) === settings.routeFilter));
const first = firstAvailable(upcoming);
// A future verification date must not publish a tariff early.
result.fare = settings.infoFareEnabled && first ? settings.fareRules.find(rule => rule.routeId === first.routeId && rule.checkedAt <= date)
|| settings.fareByType.find(rule => rule.cityId === cityId && rule.type === first.type && rule.checkedAt <= date) || null : null;
if (!available || !bundle) return result;
if (settings.infoPlacesEnabled && settings.places.length && bundle.routes.some(route => route.stops?.length)) {
result.places = settings.places.filter(place => place.stopId !== settings.stopId).map(place => {
for (const trip of upcoming) {
if (trip.status === 'cancelled') continue;
const path = segment(bundle, trip, settings.stopId);
if (!path) continue;
const target = path.stops.findIndex(stop => stop.id === place.stopId);
if (target < 0 || path.direction * (target - path.current) <= 0 || path.direction * (path.end - target) < 0) continue;
const span = path.direction > 0 ? path.stops.slice(path.current, target + 1) : path.stops.slice(target, path.current + 1).reverse();
const timed = span.every((stop, i) => stop.offsetMinutes !== null && (!i || span[i - 1].offsetMinutes !== null
&& path.direction * (stop.offsetMinutes - span[i - 1].offsetMinutes!) >= 0));
const minutes = timed ? Math.ceil(path.direction * (path.stops[target].offsetMinutes! - path.stops[path.current].offsetMinutes!)) : null;
return { place, departure: trip, minutes, arrivalAt: minutes === null ? null : new Date(Date.parse(trip.scheduledAt) + minutes * 60000).toISOString() };
}
return { place, departure: null, minutes: null, arrivalAt: null };
});
}
if (!settings.lastTripNoticeEnabled || settings.demo || !first || Date.parse(first.scheduledAt) - now > 30 * 60000) return result;
const firstPath = segment(bundle, first, settings.stopId);
if (!firstPath) return result;
const closingDate = localDate(Date.parse(first.scheduledAt));
if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result;
const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)]
.map(trip => [trip.id, trip])).values()] as Departure[];
const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
// Reuse the overnight boundary policy, including after-midnight service tails.
// Looking just after boarding proves that this trip starts the closing gap.
const closing = nightWindow({ ...settings, demo: false, nightMode: 'auto', nightWakeMinutes: 60, routeFilter: String(first.routeId) },
directionTrips, Date.parse(first.scheduledAt) + 60001, true);
if (closing.active) result.lastTripId = first.id;
return result;
}

View File

@@ -0,0 +1,30 @@
import { validDate } from './timetable.mjs';
const object = value => value && typeof value === 'object' && !Array.isArray(value);
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
export function validPlaces(value) {
return Array.isArray(value) && value.length <= 3 && new Set(value.map(item => item?.stopId)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['stopId', 'name'].includes(key))
&& id(item.stopId) && text(item.name, 48));
}
export function validFareRules(value) {
return Array.isArray(value) && value.length <= 32 && new Set(value.map(item => item?.routeId)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['routeId', 'price', 'payment', 'checkedAt'].includes(key))
&& id(item.routeId, 999999999) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
}
export function validFareByType(value) {
return Array.isArray(value) && value.length <= 2 && new Set(value.map(item => item?.type)).size === value.length
&& value.every(item => object(item) && Object.keys(item).every(key => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(key))
&& id(item.cityId) && ['bus', 'minibus'].includes(item.type) && text(item.price, 40) && text(item.payment, 100) && validDate(item.checkedAt));
}
// Confirmed by the operator on 2026-10-11 for Polotsk, not a provider estimate.
// Keep that verification date fixed and bind the values to the actual city.
export const confirmedPolotskFares = [
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
];
export const passengerDefaults = {
infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true,
places: [], fareRules: [], fareByType: [],
};

View File

@@ -4,11 +4,17 @@ import { colorScheme } from './presentation.mjs';
import { isTimetable } from './timetable.mjs';
import { isWeather } from './weather.mjs';
import type { Timetable, Weather } from '../types';
import { validPlaces, validFareRules, validFareByType, confirmedPolotskFares } from './passenger-settings.mjs';
import type { FareTypeRule } 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',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
journeyTranslitEnabled: false,
infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [],
fareByType: [{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }],
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
@@ -21,6 +27,11 @@ export function readSettings(): Settings {
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,
journeyTranslitEnabled: saved.journeyTranslitEnabled === true,
infoPlacesEnabled: saved.infoPlacesEnabled !== false, infoFareEnabled: saved.infoFareEnabled !== false,
lastTripNoticeEnabled: saved.lastTripNoticeEnabled !== false, weatherHintEnabled: saved.weatherHintEnabled !== false,
places: validPlaces(saved.places) ? saved.places : [], fareRules: validFareRules(saved.fareRules) ? saved.fareRules : [],
fareByType: saved.fareByType === undefined ? confirmedPolotskFares as FareTypeRule[] : validFareByType(saved.fareByType) ? saved.fareByType : [],
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,

View File

@@ -0,0 +1,19 @@
// Readable Latin spelling for Russian and Belarusian stop names. Names,
// punctuation and route numbers stay intact; this does not translate them.
const letters: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'yo', ж: 'zh', з: 'z',
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya', і: 'i', ў: 'u',
};
export function transliterateStopName(name: string): string {
return name.replace(/[А-ЯЁІЎа-яёіў]+/g, word => {
const allCaps = word.length > 1 && word === word.toUpperCase();
return Array.from(word, letter => {
const spelling = letters[letter.toLowerCase()];
if (allCaps) return spelling.toUpperCase();
return letter === letter.toUpperCase() ? spelling.charAt(0).toUpperCase() + spelling.slice(1) : spelling;
}).join('');
});
}

8
src/lib/weather-hint.ts Normal file
View File

@@ -0,0 +1,8 @@
import { weatherAvailable } from './weather.mjs';
import type { Weather } from '../types';
export function precipitationHint(data: Weather | null, now: number) {
if (!data || !weatherAvailable(data, now)) return null;
const hour = data.hourly.find(hour => Date.parse(hour.at) > now && Date.parse(hour.at) <= now + 3600000);
return hour ? Math.round(hour.precipitationProbability) : null;
}

View File

@@ -82,6 +82,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
/* Cards switch to one panel when the actual available area is too small. */
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); }
.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.info-intro[data-tabs-only=true] { display: none; }
.info-screen[data-compact=true] .info-intro[data-tabs-only=true] { display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.info-intro h2 { margin: 0; font-size: clamp(28px, 3vw, 56px); letter-spacing: -.045em; font-weight: 550; }
.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); }
@@ -103,13 +105,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
/* 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; }
@@ -132,6 +127,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.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-name-focus .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
.journey-transliteration { flex-shrink: 0; color: var(--muted); font-size: calc(clamp(14px, 2.2cqw, 20px) * var(--reading-scale, 1)); font-weight: 500; line-height: 1.2; letter-spacing: -.01em; overflow-wrap: anywhere; margin-bottom: 6px; }
.journey-name-focus .journey-transliteration { font-size: calc(clamp(17px, 3.1cqw, 28px) * var(--reading-scale, 1)); }
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(24px, 4.5cqw, 36px); font-weight: 550; line-height: 1; white-space: nowrap; transition: opacity 300ms ease-out; }
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
.journey-duration small { font-size: .72em; font-weight: 500; }
@@ -152,8 +149,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-metro[data-loop=true] .journey-station, .journey-metro[data-loop=true] .journey-continuation, .journey-metro[data-loop=true] .journey-line, .journey-metro[data-loop=true] .journey-name-layer { 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-name-focus .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
.journey-name-layer { padding-right: 22px; }
@@ -161,10 +156,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption { font-size: 14px; gap: 4px 10px; }
}
@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: auto minmax(0, 1fr) 32px; gap: 6px; }
@@ -184,10 +175,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.journey-caption-full { display: none; }
.journey-caption-compact { display: inline; }
}
@container journey-card (max-height: 260px) {
/* The same route and departure time remain visible in the persistent next-trip bar. */
.journey-route { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.journey-station, .journey-continuation, .journey-line, .journey-name-layer, .journey-duration, .service-info .journey-station .destination-heading { transition: none !important; will-change: auto !important; }
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail-window { animation: none; }
@@ -252,6 +239,50 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.info-screen[data-compact='true'] .information-tabs { display: flex; }
.info-screen[data-compact='true'] .info-card:not([data-active='true']) { display: none; }
.info-screen[data-compact='true'] .card-icon { display: none; }
.last-trip-notice { display: flex; align-items: center; gap: 9px; flex-shrink: 0; padding: 9px 12px; border-radius: 12px; color: var(--warning); background: color-mix(in srgb, var(--warning) 10%, var(--background)); font-size: clamp(14px, 1.4vw, 24px); line-height: 1.3; }
.last-trip-notice svg { flex-shrink: 0; }
.last-trip-notice small { font-size: .85em; color: var(--secondary); }
.last-trip-compact { background: transparent; padding: 3px 0 0; font-size: 13px; }
.last-trip-compact svg, .last-trip-compact small { display: none; }
.places-info { container-name: places-card; }
.places-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(var(--place-rows, 1), minmax(0, 1fr)); gap: 12px; }
.place-trip { min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px; border-bottom: 1px solid var(--line); padding: 10px 0; }
.place-trip:last-child { border-bottom: none; }
.place-copy { min-height: 0; min-width: 0; display: flex; flex-direction: column; justify-content: center; }
.places-info .place-copy .destination-heading { font-size: calc(clamp(24px, 5cqw, 52px) * var(--reading-scale, 1)); font-weight: 600; line-height: 1.13; letter-spacing: -.04em; margin: 0; overflow-wrap: anywhere; }
.place-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 10px; color: var(--secondary); font-size: clamp(18px, 3.2cqw, 30px); }
.place-details .route-badge { width: auto; min-width: 48px; height: 36px; border-radius: 10px; padding: 0 9px; font-size: 24px; }
.place-details time { display: inline-flex; align-items: baseline; gap: 8px; color: var(--accent); font-weight: 600; }
.place-details small, .place-arrival small { font-size: .6em; color: var(--secondary); font-weight: 400; }
.place-duration { display: flex; align-items: center; gap: 6px; font-size: .8em; }
.place-arrival { display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; color: var(--secondary); font-size: clamp(14px, 2cqw, 20px); }
.place-arrival time { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: flex-end; gap: 8px; color: var(--foreground); font-weight: 600; font-size: calc(clamp(30px, 5.2cqw, 54px) * var(--reading-scale, 1)); }
.place-trip .place-unavailable { font-size: 16px; margin: 8px 0 0; }
.passenger-caption { flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: clamp(13px, 1.2vw, 20px); padding-top: 12px; line-height: 1.3; }
.fare-info { container-name: fare-card; }
.fare-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1.8fr) minmax(0, .8fr); align-items: stretch; gap: 8px; text-align: center; padding: 6px 0; }
.fare-label { font-size: clamp(16px, 2cqw, 24px); color: var(--secondary); }
.fare-price, .fare-payment { min-height: 0; display: flex; align-items: center; justify-content: center; }
.fare-price .destination-heading { color: var(--accent); margin: 0; font-size: clamp(40px, 15cqw, 130px); line-height: 1.08; font-weight: 650; letter-spacing: -.06em; overflow-wrap: anywhere; }
.fare-info .fare-payment .destination-heading { color: var(--secondary); margin: 0; font-size: clamp(22px, 4cqw, 40px); font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; }
@container fare-card (max-height: 300px) {
.fare-label { display: none; }
.fare-body { grid-template-rows: minmax(0, 1.8fr) minmax(0, .8fr); padding: 0; }
}
.weather-hint { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; color: var(--secondary); font-size: clamp(15px, 2.6cqw, 24px); line-height: 1.3; }
.weather-hint svg { flex-shrink: 0; color: var(--accent); }
.weather-hint strong { color: var(--accent); white-space: nowrap; font-size: 1.2em; font-weight: 600; }
.passenger-editor { min-width: 0; border: 1px solid var(--outline); border-radius: 14px; padding: 16px; margin: 12px 0; }
.passenger-editor legend { color: var(--secondary); padding: 0 6px; font-size: 14px; }
.passenger-editor .text-button { font-size: 14px; padding: 8px 0; }
@container places-card (max-width: 440px) {
.place-trip { gap: 14px; }
.place-details { gap: 6px 10px; font-size: 21px; }
.place-details .route-badge { height: 30px; font-size: 20px; min-width: 40px; }
.place-duration { flex-basis: 100%; }
.place-arrival { font-size: 12px; }
.place-arrival time { font-size: 30px; }
}
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--secondary); font-size: clamp(14px, 1.2vw, 22px); padding-top: 14px; line-height: 1.3; }
.info-status svg { color: var(--accent); }
.info-notices { display: grid; gap: 18px; }
@@ -507,6 +538,16 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.weather-info { padding: 16px; }
}
/* Viewing distance raises type sizes; pagination preserves the available height. */
/* More cards rotate one at a time. Keep their controls above the title so the
additional tabs never consume the name's width on a short TV viewport. */
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-intro { position: static; margin: 0 0 10px; flex-shrink: 0; }
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-card > h3 { padding-right: 0; min-height: 0; display: block; }
.info-screen[data-many-blocks=true] .information-tabs { flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.info-screen[data-many-blocks=true] .information-tabs button { padding: 7px 10px; font-size: 13px; }
@media (max-width: 440px) {
.info-screen[data-many-blocks=true] .information-tabs button { font-size: 12px; padding: 6px 8px; }
.weather-hint { font-size: 14px; gap: 7px; }
}
.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); }

View File

@@ -11,9 +11,16 @@ 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 Place = { stopId: number; name: string };
export type FareTerms = { price: string; payment: string; checkedAt: string };
export type FareRule = FareTerms & { routeId: number };
export type FareTypeRule = FareTerms & { cityId: number; type: 'bus' | 'minibus' };
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
journeyTranslitEnabled: boolean;
infoPlacesEnabled: boolean; infoFareEnabled: boolean; lastTripNoticeEnabled: boolean; weatherHintEnabled: boolean;
places: Place[]; fareRules: FareRule[]; fareByType: FareTypeRule[];
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 };