feat: enhance timetable and departure handling with new utility functions and optimizations

This commit is contained in:
2026-10-25 17:26:49 +03:00
parent e239de45dd
commit 6811c0406a
8 changed files with 105 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
import { loadTimetable } from './timetable.mjs'; import { loadTimetable } from './timetable.mjs';
import { parseCancellationNotice } from '../src/lib/cancellations.mjs'; import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
import { shiftDate } from '../src/lib/timetable.mjs'; import { localDate, shiftDate } from '../src/lib/timetable.mjs';
export class ApiError extends Error { export class ApiError extends Error {
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; } constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
@@ -9,13 +9,13 @@ export class ApiError extends Error {
const clean = value => typeof value === 'string' ? value.trim() : ''; const clean = value => typeof value === 'string' ? value.trim() : '';
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : []; const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : []; const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now); export { localDate };
export function scheduledInstant(time, now) { export function scheduledInstant(time, now, today = localDate(now)) {
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time)); const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
if (!match || Number(match[1]) > 23) return null; if (!match || Number(match[1]) > 23) return null;
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow. // ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`; return `${today}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
} }
function routeInfo(route) { function routeInfo(route) {
@@ -27,23 +27,24 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
let partial = false; let partial = false;
const departures = []; const departures = [];
const routes = []; const routes = [];
const today = localDate(now), yesterday = shiftDate(today, -1);
for (const item of raw.routes) { for (const item of raw.routes) {
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; } if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
const route = routeInfo(item); const route = routeInfo(item);
routes.push(route); routes.push(route);
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; } if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
for (const arrival of item.arrivals) { for (const arrival of item.arrivals) {
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now); const scheduledAt = scheduledInstant(arrival?.scheduledTime, now, today);
if (!scheduledAt) { partial = true; continue; } if (!scheduledAt) { partial = true; continue; }
if (Date.parse(scheduledAt) + 60000 <= now) continue; if (Date.parse(scheduledAt) + 60000 <= now) continue;
const note = clean(arrival.trip?.note); const note = clean(arrival.trip?.note);
const terminal = stopNames.get(Number(arrival.trip?.endStopId)); const terminal = stopNames.get(Number(arrival.trip?.endStopId));
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name; const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':'); const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
const originTime = scheduledInstant(arrival.trip?.departureTime, now)?.slice(11, 16); const originTime = scheduledInstant(arrival.trip?.departureTime, now, today)?.slice(11, 16);
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name, departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note, destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? shiftDate(localDate(now), -1) : localDate(now), serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? yesterday : today,
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime, originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
source: 'schedule' }); source: 'schedule' });
} }

View File

@@ -13,13 +13,13 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '.
import { boardScenes, informationBlocks } from './lib/information'; import { boardScenes, informationBlocks } from './lib/information';
import { onwardJourney } from './lib/journey'; import { onwardJourney } from './lib/journey';
import { demoJourney } from './lib/demo'; import { demoJourney } from './lib/demo';
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures'; import { boardingIndex, displayDepartures, firstAvailable, inTimeOrder, longWait, nextSameRoute, readability } from './lib/departures';
import type { DisplayDeparture } from './lib/departures'; import type { DisplayDeparture } from './lib/departures';
import logo from './assets/ridebus-logo.svg?raw'; import logo from './assets/ridebus-logo.svg?raw';
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs'; import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
import type { ComponentProps, CSSProperties } from 'react'; import type { ComponentProps, CSSProperties } from 'react';
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types'; import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs'; import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, snapshotUsable } from './lib/board.mjs';
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage'; import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks'; import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
import { SettingsPanel } from './SettingsPanel'; import { SettingsPanel } from './SettingsPanel';
@@ -36,6 +36,7 @@ import { demoGuideTimetable } from './lib/demo';
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' }); const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }); const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
const noDepartures: Departure[] = [];
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' }; const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) { function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
@@ -184,20 +185,31 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
const timetable = useTimetable(settings, config), weather = useWeather(settings, config); const timetable = useTimetable(settings, config), weather = useWeather(settings, config);
const now = useClock(offset); const now = useClock(offset);
const date = localDate(now); const date = localDate(now);
const minuteKey = Math.floor(now / 60000);
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]); const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now); const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : [], [settings.nightMode, timetable.data, date, fullDepartures]); const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : noDepartures, [settings.nightMode, timetable.data, date, fullDepartures]);
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null); const data: BoardData | null = useMemo(() => liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null),
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter); [liveData, fullAvailable, timetable.data, fullDepartures, date]);
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []); // The clock ticks every second, but the lists below change only when an alert
// starts or ends, new data arrives or a trip leaves. Keep their identities
// stable so the per-second render does not rescan the whole timetable.
const currentAlerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const alertIds = currentAlerts.map(alert => alert.id).join('\n');
const alerts = useMemo(() => currentAlerts, [data, settings.routeFilter, alertIds]);
const fresh = settings.demo || isFresh(data, now, failed); const fresh = settings.demo || isFresh(data, now, failed);
const reliableAlerts = fresh && data?.alertsAvailable === true; const reliableAlerts = fresh && data?.alertsAvailable === true;
const source = fullAvailable ? fullDepartures : data?.departures || noDepartures;
// Resolve ambiguous cancellations before applying the operator's route filter. // Resolve ambiguous cancellations before applying the operator's route filter.
const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts); // `alerts` is already limited to active ones, so the result does not depend on the clock.
const statusById = new Map(allDepartures.map(item => [item.id, item])); const statusById = useMemo(() => new Map(displayDepartures(source, alerts, settings.stopId, now, reliableAlerts).map(item => [item.id, item])),
const departures = rawDepartures.map(item => statusById.get(item.id)!); [source, alerts, settings.stopId, reliableAlerts]);
const timeline = useMemo(() => inTimeOrder(source), [source]);
const usable = fullAvailable || snapshotUsable(data, now);
const boarding = usable ? boardingIndex(timeline, now) : timeline.length;
const departures = useMemo(() => timeline.slice(boarding).filter(item => !settings.routeFilter || String(item.routeId) === settings.routeFilter).map(item => statusById.get(item.id)!),
[timeline, boarding, settings.routeFilter, statusById]);
const first = firstAvailable(departures); 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]); 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 // 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. // every clock digit: full-route searches should not tax the TV's CPU.
@@ -205,9 +217,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
[settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]); [settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
const [placePages, setPlacePages] = useState(1); const [placePages, setPlacePages] = useState(1);
const availablePlaces = !!guide.places.length, availableFare = !!guide.fare; 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]); // The clock only matters here for the bundle's availability window.
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, fullAvailable, minuteKey]);
const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4))))); const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4)))));
const night = nightWindow(settings, displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), now, nightAvailable); const nightStatuses = useMemo(() => displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), [nightDepartures, alerts, settings.stopId, reliableAlerts]);
const night = nightWindow(settings, nightStatuses, now, nightAvailable);
const reading = readability(settings); const reading = readability(settings);
const held = shouldHoldScene(settings, first, now); const held = shouldHoldScene(settings, first, now);
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour'); const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react'; import { memo, useMemo } from 'react';
import { ArrowUpRight } from './Icons'; import { ArrowUpRight } from './Icons';
import type { Config, Stop } from './types'; import type { Config, Stop } from './types';
import { createPassengerQr, passengerLink } from './lib/passenger.mjs'; import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) { // Hundreds of modules: skip the clock-driven re-render of the info screen.
export const PassengerQr = memo(function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId); const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
const qr = useMemo(() => createPassengerQr(url), [url]); const qr = useMemo(() => createPassengerQr(url), [url]);
const stopName = stop?.name || config.stopName; const stopName = stop?.name || config.stopName;
@@ -15,4 +16,4 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und
<div className="qr-description"><p>Наведите камеру на QR-код</p> <div className="qr-description"><p>Наведите камеру на QR-код</p>
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a> <a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
</div></div></>; </div></div></>;
} });

View File

@@ -1,11 +1,14 @@
import { validDate } from './timetable.mjs'; import { localDate, validDate } from './timetable.mjs';
export const FRESH_MS = 90000; export const FRESH_MS = 90000;
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000; 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); export const minskDate = localDate;
export function snapshotUsable(data, now) {
return Boolean(data && !(now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS) && data.serviceDate === minskDate(now));
}
export function upcomingDepartures(data, now, routeFilter = '') { export function upcomingDepartures(data, now, routeFilter = '') {
if (!data || now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS || data.serviceDate !== minskDate(now)) return []; if (!snapshotUsable(data, now)) return [];
return data.departures.filter(item => Number.isFinite(Date.parse(item.scheduledAt)) && Date.parse(item.scheduledAt) + 60000 > now && (!routeFilter || String(item.routeId) === routeFilter)); return data.departures.filter(item => Number.isFinite(Date.parse(item.scheduledAt)) && Date.parse(item.scheduledAt) + 60000 > now && (!routeFilter || String(item.routeId) === routeFilter));
} }
export function activeAlerts(alerts, now, routeFilter = '', routes = []) { export function activeAlerts(alerts, now, routeFilter = '', routes = []) {

View File

@@ -21,6 +21,7 @@ export function demoGuideTimetable(data: BoardData | null, stopId: number): Time
}; };
} }
const demoTime = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
export function createDemo(now = Date.now()): BoardData { export function createDemo(now = Date.now()): BoardData {
const routes = [ const routes = [
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' }, { id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
@@ -35,7 +36,7 @@ export function createDemo(now = Date.now()): BoardData {
const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000); const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000);
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name, return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
destination: route.destination, type: route.type, scheduledAt: time.toISOString(), destination: route.destination, type: route.type, scheduledAt: time.toISOString(),
scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(time), scheduledTime: demoTime.format(time),
note: '', source: 'schedule' }; note: '', source: 'schedule' };
}), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' }; }), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' };
} }

View File

@@ -30,6 +30,23 @@ export function displayDepartures(departures: Departure[], alerts: Alert[], stop
}); });
} }
// Timetable and server lists are already in time order; check that once per
// list, so the clock tick can locate the boarding trip with a binary search.
export function inTimeOrder<T extends Departure>(departures: readonly T[]): readonly T[] {
const times = departures.map(departure => Date.parse(departure.scheduledAt));
if (times.every((time, index) => !index || times[index - 1] <= time)) return departures;
return departures.map((departure, index) => ({ departure, time: times[index] })).sort((a, b) => a.time - b.time).map(item => item.departure);
}
// Index of the first trip still boarding (scheduled time plus one minute).
export function boardingIndex(departures: readonly Departure[], now: number) {
let low = 0, high = departures.length;
while (low < high) {
const middle = (low + high) >> 1;
if (Date.parse(departures[middle].scheduledAt) + 60000 > now) high = middle; else low = middle + 1;
}
return low;
}
export function firstAvailable(departures: DisplayDeparture[]) { export function firstAvailable(departures: DisplayDeparture[]) {
return departures.find(departure => departure.status !== 'cancelled'); return departures.find(departure => departure.status !== 'cancelled');
} }

View File

@@ -2,14 +2,24 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '.
import { firstAvailable, displayDepartures } from './departures.ts'; import { firstAvailable, displayDepartures } from './departures.ts';
import { activeAlerts, upcomingDepartures } from './board.mjs'; import { activeAlerts, upcomingDepartures } from './board.mjs';
import { nightWindow } from './night.ts'; import { nightWindow } from './night.ts';
import { normalizedRouteNumber } from './cancellations.mjs';
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types'; import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
import type { DisplayDeparture } from './departures'; import type { DisplayDeparture } from './departures';
type Segment = { stops: NonNullable<Timetable['routes'][number]['stops']>; current: number; end: number; direction: number }; type Route = Timetable['routes'][number];
type Segment = { stops: NonNullable<Route['stops']>; current: number; end: number; direction: number };
// Bundles are not mutated, so each route's stop order is sorted once, not per trip.
const ordered = new WeakMap<Route, Segment['stops']>();
function routeStops(route: Route | undefined) {
if (!route?.stops) return undefined;
let stops = ordered.get(route);
if (!stops) ordered.set(route, stops = route.stops.slice().sort((a, b) => a.sequence - b.sequence));
return stops;
}
// The actual trip endpoints matter: a route's full topology is not a promise // 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. // that this particular bus reaches every stop, or travels in both directions.
function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null { 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); const stops = routeStops(bundle.routes.find(route => route.id === trip.routeId));
if (!stops?.length || !trip.originStopId || !trip.endStopId) return null; 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 start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
const current = stops.findIndex(s => s.id === stopId); const current = stops.findIndex(s => s.id === stopId);
@@ -55,8 +65,11 @@ export function passengerGuide(settings: Settings, bundle: Timetable | null, dat
if (!firstPath) return result; if (!firstPath) return result;
const closingDate = localDate(Date.parse(first.scheduledAt)); 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; if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result;
// Cancellations only match trips with the same route number, so the narrower
// pool resolves them exactly as the complete two-day timetable would.
const number = normalizedRouteNumber(first.routeNumber);
const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)] const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)]
.map(trip => [trip.id, trip])).values()] as Departure[]; .filter(trip => normalizedRouteNumber(trip.routeNumber) === number).map(trip => [trip.id, trip])).values()] as Departure[];
const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip => const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction); trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
// Reuse the overnight boundary policy, including after-midnight service tails. // Reuse the overnight boundary policy, including after-midnight service tails.

View File

@@ -30,30 +30,47 @@ export function timetableAvailable(bundle, now) {
return Boolean(bundle && now >= Date.parse(bundle.updatedAt) - 60000 && now < Date.parse(bundle.expiresAt) return Boolean(bundle && now >= Date.parse(bundle.updatedAt) - 60000 && now < Date.parse(bundle.expiresAt)
&& now - Date.parse(bundle.updatedAt) <= TIMETABLE_MAX_MS && bundle.calendarYears.includes(Number(localDate(now).slice(0, 4)))); && now - Date.parse(bundle.updatedAt) <= TIMETABLE_MAX_MS && bundle.calendarYears.includes(Number(localDate(now).slice(0, 4))));
} }
const routeOrder = new Intl.Collator('ru', { numeric: true });
// Expand civil service dates, retaining the next date for arrivals past midnight. // Expand civil service dates, retaining the next date for arrivals past midnight.
export function timetableDepartures(bundle, date) { function expandTimetable(bundle, date) {
if (!bundle) return [];
const result = []; const result = [];
const holidays = new Set(bundle.holidays); const holidays = new Set(bundle.holidays);
const tomorrow = shiftDate(date, 1);
for (let day = -1; day <= 1; day++) { for (let day = -1; day <= 1; day++) {
const serviceDate = shiftDate(date, day); const serviceDate = shiftDate(date, day);
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue; if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7; const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday'; const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday';
const midnight = Date.parse(`${serviceDate}T00:00:00+03:00`);
for (const route of bundle.routes) { for (const route of bundle.routes) {
const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate)); const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate));
const schedule = eligible.find(schedule => schedule.dayType === dayType) || (dayType === 'holiday' ? eligible.find(schedule => schedule.dayType === 'sunday') : null); const schedule = eligible.find(schedule => schedule.dayType === dayType) || (dayType === 'holiday' ? eligible.find(schedule => schedule.dayType === 'sunday') : null);
for (const [index, trip] of (schedule?.trips || []).entries()) { for (const [index, trip] of (schedule?.trips || []).entries()) {
if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue; if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue;
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`) + trip.arrivalSeconds * 1000).toISOString(); const at = midnight + trip.arrivalSeconds * 1000;
const arrivalDate = localDate(Date.parse(scheduledAt)); const arrivalDate = localDate(at);
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue; if (arrivalDate < date || arrivalDate > tomorrow) continue;
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number, const scheduledAt = new Date(at).toISOString();
result.push({ at, departure: { id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt, routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId, serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' }); scheduledTime: new Date(at + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' } });
} }
} }
} }
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.routeNumber.localeCompare(b.routeNumber, 'ru', { numeric: true })); return result.sort((a, b) => a.at - b.at || routeOrder.compare(a.departure.routeNumber, b.departure.routeNumber)).map(item => item.departure);
}
// A loaded bundle is never mutated. The board, night mode and passenger guide
// expand the same few dates, so share one frozen result per bundle and date.
const expanded = new WeakMap();
export function timetableDepartures(bundle, date) {
if (!bundle) return [];
let dates = expanded.get(bundle);
if (!dates) expanded.set(bundle, dates = new Map());
let result = dates.get(date);
if (!result) {
if (dates.size >= 4) dates.delete(dates.keys().next().value);
dates.set(date, result = Object.freeze(expandTimetable(bundle, date)));
}
return result;
} }