From 6811c0406a6e0c3c9d038b965e0b165b309a4411 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sun, 25 Oct 2026 17:26:49 +0300 Subject: [PATCH] feat: enhance timetable and departure handling with new utility functions and optimizations --- server/board.mjs | 15 ++++++++------- src/App.tsx | 39 ++++++++++++++++++++++++++------------ src/PassengerQr.tsx | 7 ++++--- src/lib/board.mjs | 9 ++++++--- src/lib/demo.ts | 3 ++- src/lib/departures.ts | 17 +++++++++++++++++ src/lib/passenger-guide.ts | 19 ++++++++++++++++--- src/lib/timetable.mjs | 33 ++++++++++++++++++++++++-------- 8 files changed, 105 insertions(+), 37 deletions(-) diff --git a/server/board.mjs b/server/board.mjs index 7082166..076a64c 100644 --- a/server/board.mjs +++ b/server/board.mjs @@ -1,6 +1,6 @@ import { loadTimetable } from './timetable.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 { 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 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) : []; -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)); if (!match || Number(match[1]) > 23) return null; // 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) { @@ -27,23 +27,24 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) { let partial = false; const departures = []; const routes = []; + const today = localDate(now), yesterday = shiftDate(today, -1); for (const item of raw.routes) { if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; } const route = routeInfo(item); routes.push(route); if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; } 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 (Date.parse(scheduledAt) + 60000 <= now) continue; const note = clean(arrival.trip?.note); const terminal = stopNames.get(Number(arrival.trip?.endStopId)); 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 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, 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, source: 'schedule' }); } diff --git a/src/App.tsx b/src/App.tsx index 3b6e060..907f46a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,13 +13,13 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '. import { boardScenes, informationBlocks } from './lib/information'; import { onwardJourney } from './lib/journey'; import { demoJourney } from './lib/demo'; -import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures'; +import { boardingIndex, displayDepartures, firstAvailable, inTimeOrder, 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 { 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 { activeAlerts, isFresh, minutesUntil, shouldHoldScene, snapshotUsable } from './lib/board.mjs'; import { hasSavedSettings, readSettings, saveSettings } from './lib/storage'; import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks'; 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 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 noDepartures: Departure[] = []; const sceneNames: Record = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' }; 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 now = useClock(offset); const date = localDate(now); + const minuteKey = Math.floor(now / 60000); const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]); const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now); - const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : [], [settings.nightMode, timetable.data, date, fullDepartures]); - const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null); - const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter); - const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []); + 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 = useMemo(() => liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null), + [liveData, fullAvailable, timetable.data, fullDepartures, date]); + // 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 reliableAlerts = fresh && data?.alertsAvailable === true; + const source = fullAvailable ? fullDepartures : data?.departures || noDepartures; // 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)!); + // `alerts` is already limited to active ones, so the result does not depend on the clock. + const statusById = useMemo(() => new Map(displayDepartures(source, alerts, settings.stopId, now, reliableAlerts).map(item => [item.id, item])), + [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 minuteKey = Math.floor(now / 60000); const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]); // Work once per minute or when the nearest trip/config/data changes, not on // every clock digit: full-route searches should not tax the TV's CPU. @@ -205,9 +217,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState; [settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]); const [placePages, setPlacePages] = useState(1); const availablePlaces = !!guide.places.length, availableFare = !!guide.fare; - const journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId), [settings.demo, settings.stopId, config.cityId, timetable.data, first, now]); + // 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 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 held = shouldHoldScene(settings, first, now); const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour'); diff --git a/src/PassengerQr.tsx b/src/PassengerQr.tsx index e3ebba9..7a2f8e9 100644 --- a/src/PassengerQr.tsx +++ b/src/PassengerQr.tsx @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { ArrowUpRight } from './Icons'; import type { Config, Stop } from './types'; 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 qr = useMemo(() => createPassengerQr(url), [url]); const stopName = stop?.name || config.stopName; @@ -15,4 +16,4 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und

Наведите камеру на QR-код

{new URL(url).host}
; -} +}); diff --git a/src/lib/board.mjs b/src/lib/board.mjs index 6fa1394..6d15430 100644 --- a/src/lib/board.mjs +++ b/src/lib/board.mjs @@ -1,11 +1,14 @@ -import { validDate } from './timetable.mjs'; +import { localDate, 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); +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 = '') { - 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)); } export function activeAlerts(alerts, now, routeFilter = '', routes = []) { diff --git a/src/lib/demo.ts b/src/lib/demo.ts index af8bc96..416f832 100644 --- a/src/lib/demo.ts +++ b/src/lib/demo.ts @@ -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 { const routes = [ { 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); return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name, 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' }; }), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' }; } diff --git a/src/lib/departures.ts b/src/lib/departures.ts index e479774..ecaa681 100644 --- a/src/lib/departures.ts +++ b/src/lib/departures.ts @@ -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(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[]) { return departures.find(departure => departure.status !== 'cancelled'); } diff --git a/src/lib/passenger-guide.ts b/src/lib/passenger-guide.ts index bbddbd0..282e55c 100644 --- a/src/lib/passenger-guide.ts +++ b/src/lib/passenger-guide.ts @@ -2,14 +2,24 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '. import { firstAvailable, displayDepartures } from './departures.ts'; import { activeAlerts, upcomingDepartures } from './board.mjs'; import { nightWindow } from './night.ts'; +import { normalizedRouteNumber } from './cancellations.mjs'; import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types'; import type { DisplayDeparture } from './departures'; -type Segment = { stops: NonNullable; current: number; end: number; direction: number }; +type Route = Timetable['routes'][number]; +type Segment = { stops: NonNullable; 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(); +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 // that this particular bus reaches every stop, or travels in both directions. function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null { - const stops = bundle.routes.find(route => route.id === trip.routeId)?.stops?.slice().sort((a, b) => a.sequence - b.sequence); + const stops = routeStops(bundle.routes.find(route => route.id === trip.routeId)); if (!stops?.length || !trip.originStopId || !trip.endStopId) return null; const start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId); const current = stops.findIndex(s => s.id === stopId); @@ -55,8 +65,11 @@ export function passengerGuide(settings: Settings, bundle: Timetable | null, dat if (!firstPath) return result; const closingDate = localDate(Date.parse(first.scheduledAt)); if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result; + // 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)] - .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 => trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction); // Reuse the overnight boundary policy, including after-midnight service tails. diff --git a/src/lib/timetable.mjs b/src/lib/timetable.mjs index 2046822..fb642dc 100644 --- a/src/lib/timetable.mjs +++ b/src/lib/timetable.mjs @@ -30,30 +30,47 @@ export function timetableAvailable(bundle, now) { 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)))); } +const routeOrder = new Intl.Collator('ru', { numeric: true }); // Expand civil service dates, retaining the next date for arrivals past midnight. -export function timetableDepartures(bundle, date) { - if (!bundle) return []; +function expandTimetable(bundle, date) { const result = []; const holidays = new Set(bundle.holidays); + const tomorrow = shiftDate(date, 1); for (let day = -1; day <= 1; day++) { const serviceDate = shiftDate(date, day); if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue; const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7; 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) { 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); for (const [index, trip] of (schedule?.trips || []).entries()) { 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 arrivalDate = localDate(Date.parse(scheduledAt)); - if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue; - result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number, + const at = midnight + trip.arrivalSeconds * 1000; + const arrivalDate = localDate(at); + if (arrivalDate < date || arrivalDate > tomorrow) continue; + 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, 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; }