feat: add service window component and related functionality
- Introduced ServiceWindow component to display the last departure today and the first tomorrow. - Implemented cancellation notice parsing to handle route cancellations effectively. - Enhanced departure display logic to include cancellation alerts and status updates. - Created information blocks for displaying service information, weather, and QR codes. - Added tests for departure features, information handling, and service window functionality. - Updated fixtures for testing various scenarios including cancellations and long wait times.
This commit is contained in:
128
src/App.tsx
128
src/App.tsx
@@ -1,18 +1,21 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Close, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { RouteCloud } from './RouteCloud';
|
||||
import { ServiceWindow } from './ServiceWindow';
|
||||
import { AlertCard } from './AlertCard';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
import { useIdleControls } from './useIdleControls';
|
||||
import { PassengerQr } from './PassengerQr';
|
||||
import { WeatherCard, WeatherSummary } from './WeatherBlock';
|
||||
import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||
import { localDate, shiftDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
|
||||
import { boardScenes, informationBlocks, serviceWindow } from './lib/information';
|
||||
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
|
||||
import type { DisplayDeparture } from './lib/departures';
|
||||
import logo from './assets/ridebus-logo.svg?raw';
|
||||
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
|
||||
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
|
||||
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||
@@ -31,19 +34,29 @@ function Countdown({ departure, now, large = false }: { departure: Departure; no
|
||||
{minutes > 0 ? <span className="countdown-value">{parts.map(part => <span className="countdown-part" key={part.unit}><NumericText value={part.value} /><small><NumericText value={part.unit} /></small></span>)}</span> : <span className="countdown-now">сейчас</span>}
|
||||
</div>;
|
||||
}
|
||||
function ScheduledTime({ departure, now, className = '' }: { departure: Departure; now: number; className?: string }) {
|
||||
return <time className={className} dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{localDate(Date.parse(departure.scheduledAt)) > localDate(now) && <span className="departure-day">завтра</span>}</time>;
|
||||
function ScheduledTime({ departure, now, className = '', showDay = true }: { departure: Departure; now: number; className?: string; showDay?: boolean }) {
|
||||
return <time className={className} dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{showDay && localDate(Date.parse(departure.scheduledAt)) > localDate(now) && <span className="departure-day">завтра</span>}</time>;
|
||||
}
|
||||
function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) {
|
||||
return <div className={`departure-status ${large ? 'departure-status-large' : ''}`}>
|
||||
{departure.status === 'cancelled' ? <span className="cancelled-label"><Close size={22} />Отменён</span> : <Countdown departure={departure} now={now} large={large} />}
|
||||
{departure.status === 'notice' && <span className="departure-change">Есть изменения</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
|
||||
data: BoardData | null; departures: Departure[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean;
|
||||
export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: {
|
||||
data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings;
|
||||
}) {
|
||||
const first = departures[0];
|
||||
const first = firstAvailable(departures);
|
||||
const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined;
|
||||
const waiting = longWait(settings, first, now);
|
||||
const reading = readability(settings);
|
||||
const table = useRef<HTMLDivElement>(null);
|
||||
const [tableHeight, setTableHeight] = useState(360);
|
||||
const [page, setPage] = useState(0);
|
||||
const capacity = Math.max(2, Math.floor(tableHeight / 62));
|
||||
const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
|
||||
const capacity = Math.max(1, Math.floor(tableHeight / reading.rowHeight));
|
||||
const remaining = departures.filter(departure => departure.id !== first?.id);
|
||||
const visible: { items: DisplayDeparture[]; start: number; count: number; pages: number; size: number } = departurePage(remaining, rowCount, capacity, page);
|
||||
const rows = Math.max(1, Math.min(rowCount, visible.size));
|
||||
useLayoutEffect(() => {
|
||||
if (!table.current) return;
|
||||
@@ -53,46 +66,50 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (paused || visible.pages < 2) return;
|
||||
const timer = setInterval(() => setPage(value => value + 1), 7000);
|
||||
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, visible.pages]);
|
||||
}, [paused, visible.pages, reading.calm]);
|
||||
return <div className="departure-layout">
|
||||
<section className="departure-content" aria-label="Ближайшие рейсы">
|
||||
<section className="departure-content" data-long-wait={waiting} data-next={Boolean(next)} aria-label="Ближайшие рейсы">
|
||||
<div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div>
|
||||
{first ? <div className="hero-departure">
|
||||
{first ? <div className="hero-departure" data-long-wait={waiting}>
|
||||
<RouteBadge number={first.routeNumber} large />
|
||||
<div className="hero-destination"><div className="transport-kind"><BusFront size={19} /> {first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}</div><FittedDestination text={first.destination} as="h2" />
|
||||
<div className="hero-details"><span>{first.note || first.routeName}</span><ScheduledTime departure={first} now={now} className="schedule-time" /></div></div>
|
||||
<Countdown departure={first} now={now} large />
|
||||
</div> : <div className="empty-departures"><BusFront size={46} /><h2>{loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
|
||||
<p>{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
|
||||
<div className="next-heading"><span className="eyebrow">СЛЕДОМ ПРИБУДУТ</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}–{visible.start + visible.items.length} из {visible.count}</span> : <ArrowDown size={22} />}</div>
|
||||
{!waiting && <div className="hero-details"><span>{first.note || first.routeName}</span><ScheduledTime departure={first} now={now} className="schedule-time" /></div>}
|
||||
{next && <div className="hero-next"><span>Следующий №{first.routeNumber}</span><ScheduledTime departure={next} now={now} /></div>}
|
||||
</div>
|
||||
{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>}
|
||||
<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" key={`position-${index}`}>
|
||||
{visible.items.map((departure, index) => <article className="departure-row" data-status={departure.status} key={`position-${index}`}>
|
||||
<RouteBadge number={departure.routeNumber} />
|
||||
<div className="row-destination"><FittedDestination text={departure.destination} rowHeight={tableHeight / rows} /><p>{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}</p><ScheduledTime departure={departure} now={now} className="row-time-compact" /></div>
|
||||
<ScheduledTime departure={departure} now={now} className="row-time" />
|
||||
<Countdown departure={departure} now={now} />
|
||||
<DepartureStatus departure={departure} now={now} />
|
||||
</article>)}
|
||||
{first && departures.length === 1 && <p className="empty-following">Это последний доступный рейс</p>}
|
||||
{!first && <div className="quiet-placeholder"><span /><span /><span /></div>}
|
||||
{!departures.length && <div className="quiet-placeholder"><span /><span /><span /></div>}
|
||||
</div>
|
||||
</section>
|
||||
{alerts[0] && <div className="transport-notice"><Info /><span>{alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}</span></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number }) {
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, service }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; service: ComponentProps<typeof ServiceWindow> }) {
|
||||
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;
|
||||
useLayoutEffect(() => {
|
||||
if (!grid.current) return;
|
||||
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330); });
|
||||
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
|
||||
observer.observe(grid.current);
|
||||
return () => observer.disconnect();
|
||||
}, [alerts.length]);
|
||||
}, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
|
||||
useEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
@@ -103,13 +120,16 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
|
||||
}, 3500);
|
||||
return () => { clearTimeout(start); clearInterval(timer); };
|
||||
}, [seconds, paused, page]);
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} aria-label="Информация для пассажиров">
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length}
|
||||
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 && <nav className="information-tabs" aria-label="Информация на вертикальном экране">{['Маршруты', 'Погода', 'RideBus'].map((name, index) => <button key={name} aria-current={page % 3 === index ? 'page' : undefined} onClick={() => onPage(index)}>{name}</button>)}</nav>}</div>
|
||||
{!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">
|
||||
<section className="info-card routes-info" data-active={page % 3 === 0}><h3>Маршруты</h3><RouteCloud routes={data?.routes || []} paused={paused} /></section>
|
||||
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
|
||||
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
|
||||
{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 === '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>)}
|
||||
</div>}
|
||||
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
|
||||
</section>;
|
||||
@@ -138,25 +158,38 @@ export default function App() {
|
||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
||||
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
||||
const departures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||
const fresh = settings.demo || isFresh(data, now, failed);
|
||||
const held = shouldHoldScene(settings, departures[0], now);
|
||||
const reliableAlerts = fresh && data?.alertsAvailable === true;
|
||||
// Resolve ambiguous cancellations before applying the operator's route filter.
|
||||
const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts);
|
||||
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
||||
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
||||
const first = firstAvailable(departures);
|
||||
const service = serviceWindow(allDepartures.filter(item => item.status !== 'cancelled'), now, settings.routeFilter);
|
||||
const reading = readability(settings);
|
||||
const held = shouldHoldScene(settings, first, now);
|
||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
||||
const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info'];
|
||||
const hasAlerts = alerts.length > 0;
|
||||
const scenes = useMemo(() => boardScenes(settings, hasAlerts, urgent), [settings, hasAlerts, urgent]);
|
||||
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const progress = Math.min(1, elapsed / (seconds * 1000));
|
||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused });
|
||||
sceneState.current = { scenes, scene, held, settingsOpen, paused };
|
||||
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm });
|
||||
sceneState.current = { scenes, scene, held, settingsOpen, paused, calm: reading.calm };
|
||||
const showNotice = useCallback((text: string) => {
|
||||
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
||||
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
|
||||
}, []);
|
||||
const goTo = useCallback((next: Scene) => {
|
||||
if (!sceneState.current.scenes.includes(next)) return;
|
||||
if (sceneState.current.held && next !== 'departures') { showNotice('Главный экран удерживается: до ближайшего рейса не больше 2 минут'); return; }
|
||||
const change = () => { if (sceneState.current.scene === 'info' && next !== 'info') setInfoPage(value => value + 1); setScene(next); setStartedAt(Date.now()); };
|
||||
if (document.startViewTransition && !matchMedia('(prefers-reduced-motion: reduce)').matches) document.startViewTransition(() => flushSync(change));
|
||||
if (document.startViewTransition && !sceneState.current.calm && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
const transition = document.startViewTransition(() => flushSync(change));
|
||||
void transition.ready.catch(() => {}); // Resizing can interrupt the animation after the scene has changed.
|
||||
}
|
||||
else change();
|
||||
}, [showNotice]);
|
||||
const nextScene = useCallback(() => {
|
||||
@@ -180,11 +213,11 @@ export default function App() {
|
||||
if (held) { setScene('departures'); setStartedAt(Date.now()); }
|
||||
else setStartedAt(Date.now());
|
||||
}, [held]);
|
||||
useEffect(() => { if (urgent && scene === 'ad') { setScene('info'); setStartedAt(Date.now()); } }, [urgent, scene]);
|
||||
useEffect(() => { if (!scenes.includes(scene)) { setScene(urgent ? 'info' : 'departures'); setStartedAt(Date.now()); } }, [scenes, urgent, scene]);
|
||||
useEffect(() => {
|
||||
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
|
||||
if (!held && elapsed >= seconds * 1000) nextScene();
|
||||
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]);
|
||||
if (scenes.length > 1 && !held && elapsed >= seconds * 1000) nextScene();
|
||||
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene, scenes.length]);
|
||||
useEffect(() => {
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return;
|
||||
@@ -202,24 +235,25 @@ export default function App() {
|
||||
const timeParts = clockFormat.format(now).split(':');
|
||||
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
|
||||
const direction = stopDirection(data?.stop);
|
||||
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={settings.theme} data-row-count={settings.rowCount} style={{ '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).color } as CSSProperties}>
|
||||
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}>
|
||||
<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>
|
||||
</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>}
|
||||
<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} />}
|
||||
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} weather={weather} now={now} />}
|
||||
{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>}
|
||||
</div></main>
|
||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} filled /><div className="persistent-destination"><strong>{departures[0].destination}</strong><ScheduledTime departure={departures[0]} now={now} /></div><Countdown departure={departures[0]} now={now} /></> : <span>Нет доступных рейсов</span>}</div>}
|
||||
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen ? 0 : progress})` }} /></div>
|
||||
{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-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>
|
||||
{notice && <div className="toast" role="status">{notice}</div>}
|
||||
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
|
||||
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,17 @@ export function FittedDestination({ text, rowHeight, as: Tag = 'h3' }: { text: s
|
||||
useLayoutEffect(() => {
|
||||
const element = heading.current, container = element?.parentElement;
|
||||
if (!element || !container) return;
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
const fit = () => {
|
||||
element.style.fontSize = '';
|
||||
const base = parseFloat(getComputedStyle(element).fontSize);
|
||||
const headingCss = getComputedStyle(element);
|
||||
let base = parseFloat(headingCss.fontSize);
|
||||
if (context && element.clientWidth > 0) {
|
||||
context.font = `${headingCss.fontStyle} ${headingCss.fontWeight} ${headingCss.fontSize} ${headingCss.fontFamily}`;
|
||||
const longestWord = Math.max(1, ...text.split(/\s+/).map(word => context.measureText(word).width));
|
||||
base = Math.max(Math.min(16, base), Math.min(base, base * element.clientWidth / longestWord));
|
||||
element.style.fontSize = `${base}px`;
|
||||
}
|
||||
const detailsHeight = Array.from(container.children).filter(child => child !== element).reduce((height, child) => {
|
||||
const css = getComputedStyle(child);
|
||||
return height + (css.display === 'none' || css.position === 'absolute' ? 0 : child.clientHeight + parseFloat(css.marginTop) + parseFloat(css.marginBottom));
|
||||
|
||||
22
src/ServiceWindow.tsx
Normal file
22
src/ServiceWindow.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import type { Departure } from './types';
|
||||
|
||||
export function ServiceWindow({ lastToday, firstTomorrow, available, tomorrowAvailable, loading, now }: {
|
||||
lastToday: Departure | null; firstTomorrow: Departure | null; available: boolean;
|
||||
tomorrowAvailable: boolean; loading: boolean; now: number;
|
||||
}) {
|
||||
return <><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>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons';
|
||||
import { schemesForTheme } from './lib/presentation.mjs';
|
||||
import { informationBlocks, informationOptions } from './lib/information';
|
||||
import { useSettingsNavigation } from './useSettingsNavigation';
|
||||
import type { BoardData, Config, Settings, Stop } from './types';
|
||||
|
||||
@@ -83,12 +84,21 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
<h4>Цветовая схема</h4><fieldset className="theme-picker"><legend className="sr-only">Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона</legend>
|
||||
{schemesForTheme(draft.theme).map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={(draft.theme === 'light' ? draft.lightColorScheme : draft.colorScheme) === scheme.id} onChange={() => update(draft.theme === 'light' ? 'lightColorScheme' : 'colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
|
||||
</fieldset><p className="form-hint">У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.</p>
|
||||
<h4>Читаемость</h4><div className="form-grid">
|
||||
<label>Профиль экрана<select value={draft.displayProfile} onChange={event => update('displayProfile', event.target.value as Settings['displayProfile'])}><option value="tv">Телевизор</option><option value="street">Уличное табло</option></select></label>
|
||||
<label>Расстояние просмотра<select value={draft.viewingDistance} onChange={event => update('viewingDistance', event.target.value as Settings['viewingDistance'])}><option value="near">Близко</option><option value="medium">Средне</option><option value="far">Далеко</option></select></label>
|
||||
</div><p className="form-hint">Уличный профиль — крупнее текст и спокойнее анимация. Для просмотра издалека меньше рейсов показывается одновременно; остальные сменяются страницами.</p>
|
||||
</section>
|
||||
<section hidden={page !== 2} data-settings-section="2" id="settings-section-2"><h3>Экраны и переходы</h3>
|
||||
<div className="form-grid"><label>Главный экран, сек<input type="number" min="5" max="120" required value={draft.mainSeconds} onChange={event => update('mainSeconds', Number(event.target.value))} /></label>
|
||||
<label>Информация, сек<input type="number" min="5" max="60" required value={draft.infoSeconds} onChange={event => update('infoSeconds', Number(event.target.value))} /></label></div>
|
||||
<label>Следующие рейсы<select value={draft.rowCount} onChange={event => update('rowCount', Number(event.target.value))}>{[2,3,4,5,6].map(number => <option key={number} value={number}>{number}</option>)}</select></label>
|
||||
<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>
|
||||
{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>}
|
||||
</section>
|
||||
<section hidden={page !== 3} data-settings-section="3" id="settings-section-3"><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
|
||||
<label className="switch-row"><span>Добавить в цикл<small>По умолчанию отключён</small></span><input type="checkbox" checked={draft.adsEnabled} onChange={event => update('adsEnabled', event.target.checked)} /></label>
|
||||
@@ -98,7 +108,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, 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, 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>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
|
||||
export const FRESH_MS = 90000;
|
||||
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
export const minskDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
@@ -20,5 +22,8 @@ export function isBoardData(value, cityId, stopId) {
|
||||
return Boolean(value && value.cityId === cityId && value.stop?.id === stopId && value.mode === 'live' && Array.isArray(value.departures)
|
||||
&& Array.isArray(value.routes) && Array.isArray(value.alerts) && typeof value.serviceDate === 'string' && Number.isFinite(Date.parse(value.updatedAt))
|
||||
&& value.departures.every(item => typeof item.id === 'string' && Number.isFinite(Date.parse(item.scheduledAt)))
|
||||
&& value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)));
|
||||
&& value.alerts.every(alert => typeof alert.id === 'string' && Array.isArray(alert.routeIds) && Array.isArray(alert.routeNumbers)
|
||||
&& (alert.cancellations === undefined || Array.isArray(alert.cancellations) && alert.cancellations.every(target =>
|
||||
target && typeof target.routeNumber === 'string' && Number.isSafeInteger(target.stopId) && target.stopId > 0
|
||||
&& validDate(target.serviceDate) && typeof target.time === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(target.time)))));
|
||||
}
|
||||
|
||||
34
src/lib/cancellations.mjs
Normal file
34
src/lib/cancellations.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
|
||||
const stopName = value => String(value || '').normalize('NFKC').trim().toLocaleLowerCase('ru').replace(/ё/g, 'е').replace(/\s+/g, ' ');
|
||||
export const normalizedRouteNumber = value => String(value || '').replace(/[\s№]/g, '').toLocaleLowerCase('ru');
|
||||
|
||||
// The existing Telegram importer publishes this exact, reviewed notice format.
|
||||
// Free-form prose and alert validity periods are not evidence of a cancelled trip.
|
||||
export function parseCancellationNotice(message, routeNumbers, stops) {
|
||||
if (typeof message !== 'string' || message.length > 5000) return [];
|
||||
const header = /^(\d{2})\.(\d{2})\.(\d{4}): не выполняются рейсы:\s*\n([\s\S]+)$/u.exec(message.trim());
|
||||
if (!header) return [];
|
||||
const serviceDate = `${header[3]}-${header[2]}-${header[1]}`;
|
||||
if (!validDate(serviceDate)) return [];
|
||||
const numbers = [...new Set(routeNumbers.map(normalizedRouteNumber))].filter(Boolean);
|
||||
let number = numbers.length === 1 ? numbers[0] : null;
|
||||
const targets = [];
|
||||
for (const line of header[4].split('\n').map(value => value.trim()).filter(Boolean)) {
|
||||
const route = /^Маршрут №([^\s:]+):$/u.exec(line);
|
||||
if (route) {
|
||||
number = normalizedRouteNumber(route[1]);
|
||||
if (!numbers.includes(number)) return [];
|
||||
continue;
|
||||
}
|
||||
const departure = /^(.+?) — ((?:[0-2]\d:[0-5]\d)(?:,\s*[0-2]\d:[0-5]\d)*)$/u.exec(line);
|
||||
if (!departure || !number) return [];
|
||||
const times = departure[2].split(/,\s*/);
|
||||
if (times.some(time => Number(time.slice(0, 2)) > 23)) return [];
|
||||
const matches = stops.filter(stop => stopName(stop.name) === stopName(departure[1]));
|
||||
// A name shared by two platforms cannot establish the affected platform.
|
||||
if (matches.length !== 1) continue;
|
||||
for (const time of times) targets.push({ routeNumber: number, stopId: Number(matches[0].id), serviceDate, time });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
49
src/lib/departures.ts
Normal file
49
src/lib/departures.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Alert, Departure, Settings } from '../types';
|
||||
import { localDate } from './timetable.mjs';
|
||||
import { normalizedRouteNumber } from './cancellations.mjs';
|
||||
|
||||
export type DisplayDeparture = Departure & { status: 'scheduled' | 'notice' | 'cancelled'; alertId?: string };
|
||||
export const LONG_WAIT_MINUTES = 90;
|
||||
|
||||
export function displayDepartures(departures: Departure[], alerts: Alert[], stopId: number, now: number, reliableAlerts: boolean): DisplayDeparture[] {
|
||||
const cancellations = reliableAlerts ? alerts.filter(alert => alert.type === 'cancellation'
|
||||
&& (!alert.startAt || Date.parse(alert.startAt) <= now) && (!alert.endAt || Date.parse(alert.endAt) > now)) : [];
|
||||
const cancelledTrips = new Map<string, string>();
|
||||
const dates = new Map(departures.map(departure => [departure.id, cancellations.length ? localDate(Date.parse(departure.scheduledAt)) : '']));
|
||||
for (const alert of cancellations) {
|
||||
for (const target of alert.cancellations || []) {
|
||||
const matches = departures.filter(departure => normalizedRouteNumber(target.routeNumber) === normalizedRouteNumber(departure.routeNumber)
|
||||
&& (alert.routeIds.includes(departure.routeId) || alert.routeNumbers.some(value => normalizedRouteNumber(value) === normalizedRouteNumber(departure.routeNumber))
|
||||
|| !alert.routeIds.length && !alert.routeNumbers.length)
|
||||
&& (target.stopId === stopId && target.serviceDate === dates.get(departure.id) && target.time === departure.scheduledTime
|
||||
|| target.stopId === departure.originStopId && target.serviceDate === departure.serviceDate && target.time === departure.originTime));
|
||||
if (matches.length === 1) cancelledTrips.set(matches[0].id, alert.id);
|
||||
}
|
||||
}
|
||||
return departures.map(departure => {
|
||||
const number = normalizedRouteNumber(departure.routeNumber);
|
||||
const relevant = cancellations.filter(alert => alert.routeIds.includes(departure.routeId)
|
||||
|| alert.routeNumbers.some(value => normalizedRouteNumber(value) === number)
|
||||
|| !alert.routeIds.length && !alert.routeNumbers.length);
|
||||
const cancelled = cancelledTrips.get(departure.id);
|
||||
return { ...departure, status: cancelled ? 'cancelled' : relevant.length ? 'notice' : 'scheduled', alertId: cancelled || relevant[0]?.id };
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAvailable(departures: DisplayDeparture[]) {
|
||||
return departures.find(departure => departure.status !== 'cancelled');
|
||||
}
|
||||
export function nextSameRoute(departures: DisplayDeparture[], first: Departure | undefined) {
|
||||
if (!first) return undefined;
|
||||
return departures.find(departure => departure.status !== 'cancelled' && departure.routeId === first.routeId
|
||||
&& departure.destination === first.destination && Date.parse(departure.scheduledAt) > Date.parse(first.scheduledAt));
|
||||
}
|
||||
export function longWait(settings: Settings, departure: Departure | undefined, now: number) {
|
||||
return Boolean(settings.longWaitEnabled && departure && Date.parse(departure.scheduledAt) - now >= LONG_WAIT_MINUTES * 60000);
|
||||
}
|
||||
export function readability(settings: Settings) {
|
||||
const scale = { near: 1, medium: 1.15, far: 1.3 }[settings.viewingDistance];
|
||||
const rowHeight = { near: 62, medium: 80, far: 104 }[settings.viewingDistance];
|
||||
return { scale: scale * (settings.displayProfile === 'street' ? 1.12 : 1),
|
||||
rowHeight: rowHeight + (settings.displayProfile === 'street' ? 12 : 0), calm: settings.displayProfile === 'street' };
|
||||
}
|
||||
34
src/lib/information.ts
Normal file
34
src/lib/information.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Departure, Scene, Settings } from '../types';
|
||||
import { localDate, shiftDate } from './timetable.mjs';
|
||||
|
||||
export const informationOptions = [
|
||||
{ id: 'service', setting: 'infoServiceEnabled', name: 'Движение', label: 'Первый и последний рейс' },
|
||||
{ id: 'weather', setting: 'infoWeatherEnabled', name: 'Погода', label: 'Погода' },
|
||||
{ id: 'qr', setting: 'infoQrEnabled', name: 'RideBus', label: 'QR-код расписания' },
|
||||
] as const;
|
||||
|
||||
export function informationBlocks(settings: Settings) {
|
||||
return informationOptions.filter(block => settings[block.setting]);
|
||||
}
|
||||
|
||||
export function boardScenes(settings: Settings, hasAlerts: boolean, urgent: boolean): Scene[] {
|
||||
return ['departures', ...(hasAlerts || informationBlocks(settings).length ? ['info'] : []),
|
||||
...(settings.adsEnabled && !urgent ? ['ad'] : [])] as Scene[];
|
||||
}
|
||||
|
||||
// Use the complete timetable, including arrivals carried over from the previous
|
||||
// service day. A short live arrivals response cannot establish the last trip.
|
||||
export function serviceWindow(departures: Departure[], now: number, routeFilter: string) {
|
||||
const today = localDate(now);
|
||||
const todayStart = Date.parse(`${today}T00:00:00+03:00`);
|
||||
const tomorrowStart = Date.parse(`${shiftDate(today, 1)}T00:00:00+03:00`);
|
||||
const tomorrowEnd = tomorrowStart + 86400000;
|
||||
let lastToday: Departure | null = null, firstTomorrow: Departure | null = null;
|
||||
for (const departure of departures) {
|
||||
if (routeFilter && String(departure.routeId) !== routeFilter) continue;
|
||||
const at = Date.parse(departure.scheduledAt);
|
||||
if (at >= todayStart && at < tomorrowStart && (!lastToday || at > Date.parse(lastToday.scheduledAt))) lastToday = departure;
|
||||
if (at >= tomorrowStart && at < tomorrowEnd && (!firstTomorrow || at < Date.parse(firstTomorrow.scheduledAt))) firstTomorrow = departure;
|
||||
}
|
||||
return { lastToday, firstTomorrow };
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import type { Timetable, Weather } from '../types';
|
||||
|
||||
const SETTINGS_KEY = 'ridebus-board-settings-v1';
|
||||
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
|
||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
||||
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
||||
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
||||
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
||||
@@ -16,6 +19,9 @@ export function readSettings(): Settings {
|
||||
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
|
||||
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
|
||||
theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
|
||||
infoServiceEnabled: saved.infoServiceEnabled !== false, infoWeatherEnabled: saved.infoWeatherEnabled !== false, infoQrEnabled: saved.infoQrEnabled !== false,
|
||||
showNextSameRoute: saved.showNextSameRoute !== false, longWaitEnabled: saved.longWaitEnabled !== false,
|
||||
displayProfile: saved.displayProfile === 'street' ? 'street' : 'tv', viewingDistance: ['medium', 'far'].includes(saved.viewingDistance) ? saved.viewingDistance : 'near',
|
||||
adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
||||
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const TIMETABLE_MAX_MS = 7 * 86400000;
|
||||
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
const dayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
export const localDate = now => dayFormat.format(now);
|
||||
export const shiftDate = (date, days) => new Date(Date.parse(`${date}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
|
||||
export function validDate(value) {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T12:00:00Z`))
|
||||
@@ -16,6 +17,8 @@ export function isTimetable(value, cityId, stopId) {
|
||||
&& (!schedule.validUntil || validDate(schedule.validUntil)) && Array.isArray(schedule.trips) && schedule.trips.every(trip =>
|
||||
Number.isFinite(trip.arrivalSeconds) && trip.arrivalSeconds >= 0 && trip.arrivalSeconds < 172800
|
||||
&& typeof trip.destination === 'string' && typeof trip.note === 'string' && Array.isArray(trip.daysOfWeek)
|
||||
&& (trip.originStopId == null || Number.isSafeInteger(trip.originStopId) && trip.originStopId > 0)
|
||||
&& (trip.originTime == null || typeof trip.originTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(trip.originTime))
|
||||
&& Array.isArray(trip.exceptDaysOfWeek) && [...trip.daysOfWeek, ...trip.exceptDaysOfWeek].every(day => Number.isInteger(day) && day >= 1 && day <= 7)))));
|
||||
}
|
||||
export function timetableAvailable(bundle, now) {
|
||||
@@ -42,6 +45,7 @@ export function timetableDepartures(bundle, date) {
|
||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime,
|
||||
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.information-tabs { display: none; gap: 8px; }
|
||||
.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
|
||||
.information-tabs button[aria-current='page'] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--info-columns, 3), minmax(0, 1fr)); gap: 24px; }
|
||||
.info-card { min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: 24px; padding: clamp(18px, 1.7vw, 32px); display: flex; flex-direction: column; overflow: auto; container-type: size; }
|
||||
.info-card > * { flex-shrink: 0; }
|
||||
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.15; font-weight: 600; letter-spacing: -.03em; margin: 0 0 18px; }
|
||||
@@ -101,6 +101,34 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
||||
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
|
||||
.service-info { container-name: service-card; }
|
||||
.service-window { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.service-boundary { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 12px; }
|
||||
.service-boundary + .service-boundary { border-top: 1px solid var(--line); padding-top: 20px; }
|
||||
.service-label { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--secondary); font-size: clamp(17px, 3cqw, 24px); line-height: 1.25; }
|
||||
.service-label span { color: var(--muted); font-size: .8em; align-self: center; }
|
||||
.service-boundary > time { display: block; color: var(--accent); font-size: clamp(44px, min(17cqw, 19cqh), 112px); font-weight: 600; line-height: 1; }
|
||||
.service-trip { min-width: 0; display: flex; align-items: center; gap: 12px; font-size: clamp(18px, 4cqw, 26px); line-height: 1.2; }
|
||||
.service-trip .route-badge { flex-shrink: 0; width: 72px; height: 48px; border-radius: 14px; font-size: 32px; padding: 0 8px; }
|
||||
.service-trip > span:last-child { overflow-wrap: anywhere; }
|
||||
.service-info .service-empty { margin: 0; font-size: 20px; }
|
||||
.service-info .service-unavailable { margin-block: auto; text-align: center; font-size: 22px; }
|
||||
@container service-card (min-width: 600px) {
|
||||
.service-window { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 32px; }
|
||||
.service-boundary + .service-boundary { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 32px; }
|
||||
}
|
||||
@container service-card (max-height: 380px) {
|
||||
.service-window { gap: 12px; }
|
||||
.service-boundary { gap: 8px; }
|
||||
.service-boundary + .service-boundary { padding-top: 12px; }
|
||||
.service-label { font-size: 17px; }
|
||||
.service-boundary > time { font-size: clamp(40px, min(14cqw, 25cqh), 72px); }
|
||||
.service-trip { font-size: 18px; }
|
||||
.service-trip .route-badge { width: 60px; height: 40px; font-size: 28px; border-radius: 12px; }
|
||||
}
|
||||
@container service-card (min-width: 600px) and (max-height: 380px) {
|
||||
.service-boundary + .service-boundary { padding-top: 0; padding-left: 20px; }
|
||||
}
|
||||
.weather-info { container-name: weather-card; }
|
||||
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
|
||||
.weather-now { min-width: 0; display: flex; flex-direction: column; align-items: center; }
|
||||
@@ -404,7 +432,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.info-screen[data-compact='true'][data-cards='true'] { position: relative; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro { position: absolute; top: 27px; right: 20px; z-index: 1; margin: 0; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro h2 { display: none; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: 290px; min-height: 36px; text-align: left; display: flex; align-items: center; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: var(--info-tabs-width, 290px); min-height: 36px; text-align: left; display: flex; align-items: center; }
|
||||
.info-status { font-size: 13px; padding-top: 10px; }
|
||||
.persistent-next > .eyebrow { display: none; }
|
||||
.persistent-next { padding: 10px 0; }
|
||||
@@ -415,6 +443,69 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.weather-attribution { padding-top: 8px; font-size: 10px; }
|
||||
.weather-info { padding: 16px; }
|
||||
}
|
||||
/* Viewing distance raises type sizes; pagination preserves the available height. */
|
||||
.board { --reading-scale: 1; }
|
||||
.board[data-profile='street'], .board[data-distance='medium'], .board[data-distance='far'] { --hero-height: clamp(125px, calc(18vh * var(--reading-scale)), 270px); }
|
||||
.board h1 { font-size: clamp(30px, calc(3.8vw * var(--reading-scale)), 90px); }
|
||||
.board .clock { font-size: clamp(38px, calc(4.6vw * var(--reading-scale)), 108px); }
|
||||
.board .eyebrow { font-size: clamp(12px, calc(1.05vw * var(--reading-scale)), 26px); }
|
||||
.board .source-label { font-size: clamp(13px, calc(1.15vw * var(--reading-scale)), 28px); }
|
||||
.board .hero-destination h2 { font-size: min(calc(4.7vw * var(--reading-scale)), calc(var(--hero-height) * .34)); }
|
||||
.board .row-destination h3 { font-size: min(calc(54px * var(--reading-scale)), calc(3.5vw * var(--reading-scale)), calc(var(--row-height) * .4)); }
|
||||
.board .row-time { font-size: min(calc(42px * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .info-card h3 { font-size: clamp(23px, calc(2vw * var(--reading-scale)), 44px); }
|
||||
.departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(134px, calc(18vh * var(--reading-scale) + 24px), 260px); }
|
||||
.board[data-row-count='5'] .departure-content[data-next='true']:not([data-long-wait='true']), .board[data-row-count='6'] .departure-content[data-next='true']:not([data-long-wait='true']) { --hero-height: clamp(118px, calc(15vh * var(--reading-scale) + 24px), 224px); }
|
||||
.hero-next { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-top: 8px; color: var(--secondary); font-size: min(calc(22px * var(--reading-scale)), max(15px, calc(var(--hero-height) * .13))); line-height: 1.2; }
|
||||
.hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; }
|
||||
.departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; }
|
||||
.departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; }
|
||||
.cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; }
|
||||
.cancelled-label svg { width: .9em; height: .9em; }
|
||||
.departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); }
|
||||
.departure-row[data-status='cancelled'] h3 { color: var(--secondary); }
|
||||
.empty-departures[data-cancelled='true'] > svg { display: none; }
|
||||
.empty-departures[data-cancelled='true'] h2 { color: var(--warning); font-size: clamp(22px, calc(2.8vw * var(--reading-scale)), 44px); }
|
||||
.departure-content[data-long-wait='true'] { --hero-height: clamp(142px, calc(24vh * var(--reading-scale)), 300px); }
|
||||
.long-wait-time { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 8px; }
|
||||
.long-wait-time > span:first-child { color: var(--secondary); font-size: clamp(17px, calc(2vw * var(--reading-scale)), 34px); }
|
||||
.long-wait-time > time { color: var(--accent); font-size: min(calc(144px * var(--reading-scale)), calc(9vw * var(--reading-scale)), calc(var(--hero-height) * .7)); line-height: 1; font-weight: 600; white-space: nowrap; }
|
||||
.board[data-motion='calm'] .numeric-old, .board[data-motion='calm'] .destination-old { display: none; }
|
||||
.board[data-motion='calm'] .numeric-current, .board[data-motion='calm'] .destination-current { transform: none !important; }
|
||||
.board[data-motion='calm'] .numeric-glyph[data-rolling] .numeric-current, .board[data-motion='calm'] .destination-current[data-changing='true'], .board[data-motion='calm'] .scene { animation: calm-in 180ms ease-out both; }
|
||||
@keyframes calm-in { from { opacity: .35; } to { opacity: 1; } }
|
||||
@media (max-width: 1000px) {
|
||||
.board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(4vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .row-time-compact { font-size: min(calc(20px * var(--reading-scale)), calc(var(--row-height) * .21)); }
|
||||
}
|
||||
@media (max-height: 620px) and (min-width: 701px) {
|
||||
.board h1 { font-size: calc(32px * var(--reading-scale)); }
|
||||
.board .clock { font-size: calc(40px * var(--reading-scale)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.board h1 { font-size: clamp(25px, calc(6.5vw * var(--reading-scale)), 54px); }
|
||||
.board .clock { font-size: clamp(27px, calc(6.8vw * var(--reading-scale)), 56px); }
|
||||
.board .eyebrow { font-size: calc(10px * var(--reading-scale)); }
|
||||
.board .source-label { font-size: calc(12px * var(--reading-scale)); }
|
||||
.board .hero-destination h2 { font-size: min(calc(6vw * var(--reading-scale)), calc(var(--hero-height) * .3)); }
|
||||
.board .row-destination h3 { font-size: min(calc(42px * var(--reading-scale)), calc(5.7vw * var(--reading-scale)), calc(var(--row-height) * .36)); }
|
||||
.board .info-card h3 { font-size: min(calc(24px * var(--reading-scale)), 7vw); }
|
||||
.hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; }
|
||||
.departure-status { gap: 4px; }
|
||||
.departure-change { font-size: 12px; }
|
||||
.hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); }
|
||||
.hero-departure[data-long-wait='true'] .transport-kind { display: none; }
|
||||
.long-wait-time { grid-column: 2; align-items: flex-start; gap: 4px; }
|
||||
.long-wait-time > span:first-child { font-size: 16px; }
|
||||
.long-wait-time > time { font-size: min(56px, 14vw); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.board[data-distance='far'] .departure-content:not([data-long-wait='true']) { --hero-height: clamp(142px, calc(19vh * var(--reading-scale)), 220px); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); }
|
||||
.board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; }
|
||||
.board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; }
|
||||
.board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; }
|
||||
}
|
||||
::view-transition { pointer-events: none; }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -1,18 +1,23 @@
|
||||
export type Departure = {
|
||||
id: string; routeId: number; routeNumber: string; routeName: string; destination: string;
|
||||
type: string; scheduledAt: string; scheduledTime: string; note: string; source: 'schedule';
|
||||
serviceDate?: string; originStopId?: number; originTime?: string;
|
||||
};
|
||||
export type Route = { id: number; number: string; name: string; type: string };
|
||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null };
|
||||
export type Cancellation = { routeNumber: string; stopId: number; serviceDate: string; time: string };
|
||||
export type Alert = { id: string; title: string; message: string; type: string; routeNumbers: string[]; routeIds: number[]; startAt: string | null; endAt: string | null; cancellations?: Cancellation[] };
|
||||
export type Stop = { id: number; name: string; direction: string };
|
||||
export type BoardData = { cityId: number; cityName: string; stop: Stop; departures: Departure[]; routes: Route[];
|
||||
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
|
||||
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
|
||||
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
|
||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string;
|
||||
infoServiceEnabled: boolean; infoWeatherEnabled: boolean; infoQrEnabled: boolean;
|
||||
showNextSameRoute: boolean; longWaitEnabled: boolean; displayProfile: 'tv' | 'street'; viewingDistance: 'near' | 'medium' | 'far';
|
||||
adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
export type Scene = 'departures' | 'info' | 'ad';
|
||||
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[]; originStopId?: number; originTime?: string }[];
|
||||
}[] })[]; holidays: string[]; calendarYears: number[]; updatedAt: string; expiresAt: string };
|
||||
export type Weather = { cityId: number; stopId: number; updatedAt: string; observedAt: string; temperature: number; apparentTemperature: number;
|
||||
code: number; isDay: boolean; windSpeed: number; hourly: { at: string; temperature: number; code: number; precipitationProbability: number }[] };
|
||||
|
||||
Reference in New Issue
Block a user