initial commit

This commit is contained in:
2026-10-07 01:48:32 +03:00
commit 664421662c
70 changed files with 11333 additions and 0 deletions

7
server/errors.mjs Normal file
View File

@@ -0,0 +1,7 @@
export class ApiError extends Error {
constructor(status, code = 'data_unavailable') {
super(code);
this.status = status;
this.code = code;
}
}

37
server/features.mjs Normal file
View File

@@ -0,0 +1,37 @@
import { ApiError } from './errors.mjs';
const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : '';
const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0;
export function normalizeAlerts(raw, cityId) {
if (!Array.isArray(raw)) throw new ApiError(502);
const seen = new Set();
return raw.flatMap(alert => {
if (!alert || !positive(alert.id)) throw new ApiError(502);
if (alert.is_active === false || (alert.city_id != null && Number(alert.city_id) !== cityId) || seen.has(Number(alert.id))) return [];
if ((alert.route_numbers != null && (!Array.isArray(alert.route_numbers) || alert.route_numbers.some(value => !routeNumber(value))))
|| (alert.route_ids != null && (!Array.isArray(alert.route_ids) || alert.route_ids.some(value => !positive(value))))) throw new ApiError(502);
const numbers = Array.isArray(alert.route_numbers) ? alert.route_numbers.map(routeNumber).filter(Boolean) : [];
const ids = Array.isArray(alert.route_ids) ? alert.route_ids.filter(positive).map(Number) : [];
if (!numbers.length && routeNumber(alert.route_number)) numbers.push(routeNumber(alert.route_number));
if (!ids.length && positive(alert.route_id)) ids.push(Number(alert.route_id));
const startTime = Date.parse(alert.start_time);
const endTime = alert.end_time == null ? null : Date.parse(alert.end_time);
if (!Number.isFinite(startTime) || (endTime !== null && !Number.isFinite(endTime)) || typeof alert.title !== 'string' || typeof alert.message !== 'string') throw new ApiError(502);
seen.add(Number(alert.id));
return [{ id: Number(alert.id), cityId, title: alert.title.trim(), message: alert.message.trim(),
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
routeNumbers: [...new Set(numbers)].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true })), routeIds: [...new Set(ids)], isCityWide: !numbers.length && !ids.length,
startTime: new Date(startTime).toISOString(), endTime: endTime === null ? null : new Date(endTime).toISOString() }];
});
}
export function normalizeNearby(raw) {
if (!Array.isArray(raw)) throw new ApiError(502);
return raw.map(stop => {
const distance = Number(stop?.distance_meters);
if (!stop || !positive(stop.id) || typeof stop.name !== 'string' || !stop.name.trim() || stop.distance_meters == null || !Number.isFinite(distance) || distance < 0) throw new ApiError(502);
return { id: Number(stop.id), name: stop.name.trim(), direction: typeof stop.description === 'string' ? stop.description.trim() : '',
routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance) };
}).sort((a, b) => a.distance - b.distance);
}

84
server/index.mjs Normal file
View File

@@ -0,0 +1,84 @@
import http from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ApiError, createRideBusClient, parseRequest } from './ridebus.mjs';
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
const dist = resolve(root, 'dist');
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.webmanifest': 'application/manifest+json' };
export function apiHandler(client = createRideBusClient()) {
return async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (!url.pathname.startsWith('/api/')) return false;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
res.writeHead(405).end(JSON.stringify({ error: 'method_not_allowed' }));
return true;
}
try {
if (url.pathname !== '/api/data') throw new ApiError(404, 'not_found');
const data = await client.get(parseRequest(url.searchParams));
res.writeHead(200).end(JSON.stringify(data));
} catch (error) {
const known = error instanceof ApiError;
// Upstream bodies, keys and configuration never reach the browser.
res.writeHead(known ? error.status : 502).end(JSON.stringify({ error: known ? error.code : 'data_unavailable' }));
}
return true;
};
}
async function start() {
const dev = process.argv.includes('--dev');
const port = Number(process.env.PORT || 5173);
const host = process.env.HOST || '127.0.0.1';
if (!dev && !existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build before npm start');
const api = apiHandler();
const vite = dev ? await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' }) : null;
const server = http.createServer(async (req, res) => {
if (new URL(req.url, 'http://localhost').pathname === '/healthz') {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
res.writeHead(405).end();
} else {
res.writeHead(200).end(req.method === 'HEAD' ? undefined : JSON.stringify({ status: 'ok' }));
}
return;
}
if (await api(req, res)) return;
if (vite) { vite.middlewares(req, res); return; }
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end(); return; }
let path;
try { path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); } catch { res.writeHead(400).end(); return; }
let file = resolve(dist, `.${path}`);
if (file !== dist && !file.startsWith(dist + sep)) { res.writeHead(403).end(); return; }
if (!existsSync(file) || !statSync(file).isFile()) {
if (extname(path)) { res.writeHead(404).end(); return; }
file = resolve(dist, 'index.html');
}
res.setHeader('Content-Type', mime[extname(file)] || 'application/octet-stream');
res.setHeader('Cache-Control', file.includes(`${sep}assets${sep}`) ? 'public, max-age=31536000, immutable' : 'no-cache');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method === 'HEAD') { res.end(); return; }
createReadStream(file).pipe(res);
});
server.on('error', error => {
console.error(error.code === 'EADDRINUSE' ? `Port ${port} is already in use. Set PORT in .env.` : `Unable to start server: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, host, () => {
console.log(`RideBus ${dev ? 'development' : 'PWA'}: http://${host}:${port}`);
console.log(`RideBus API: ${process.env.RIDEBUS_API_KEY ? 'configured' : 'add RIDEBUS_API_KEY to .env'}`);
});
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
start().catch(error => { console.error(error.message); process.exitCode = 1; });
}

236
server/ridebus.mjs Normal file
View File

@@ -0,0 +1,236 @@
import { ApiError } from './errors.mjs';
import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs';
import { normalizeAlerts, normalizeNearby } from './features.mjs';
export { ApiError } from './errors.mjs';
function positiveId(value) {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw new ApiError(400, 'invalid_request');
}
return Number(value);
}
export function parseRequest(params) {
const resource = params.get('resource');
if (resource === 'cities') return { resource };
if (!['stops', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request');
const cityId = positiveId(params.get('cityId'));
if (resource === 'alerts') return { resource, cityId };
if (resource === 'nearby') {
const coordinate = (name, limit) => {
const value = params.get(name);
if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request');
return Number(value);
};
const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000;
if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request');
return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius };
}
if (resource === 'journey') {
const at = params.get('at');
const time = Date.parse(at);
if (!Number.isFinite(time) || new Date(time).toISOString() !== at) throw new ApiError(400, 'invalid_request');
const date = new Date(time + 10800000).toISOString().slice(0, 10);
if (date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
const tripKey = params.get('tripKey') || undefined;
if (tripKey && (!/^[\w-]+$/.test(tripKey) || tripKey.length > 2000)) throw new ApiError(400, 'invalid_request');
return { resource, cityId, stopId: positiveId(params.get('stopId')), routeId: positiveId(params.get('routeId')), at, ...(tripKey ? { tripKey } : {}) };
}
if (resource === 'timetable') {
const date = params.get('date');
if (!validDate(date) || date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
return { resource, cityId, stopId: positiveId(params.get('stopId')), date };
}
return resource === 'stops' ? { resource, cityId } : { resource, cityId, stopId: positiveId(params.get('stopId')) };
}
const record = value => {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ApiError(502);
return value;
};
const list = value => {
if (!Array.isArray(value)) throw new ApiError(502);
return value;
};
const text = value => {
if (typeof value !== 'string' || !value.trim()) throw new ApiError(502);
return value.trim();
};
const id = value => {
const result = Number(value);
if (!Number.isSafeInteger(result) || result <= 0) throw new ApiError(502);
return result;
};
export function normalizeCities(raw) {
return list(raw).filter(item => item?.is_active !== false).map(item => {
const city = record(item);
return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '' };
});
}
export function normalizeStops(raw) {
return list(raw).map(item => {
const stop = record(item);
return {
id: id(stop.id), name: text(stop.name),
direction: typeof stop.description === 'string' ? stop.description.trim() : '',
routesCount: Math.max(0, Number(stop.routes_count) || 0),
};
});
}
export function normalizeDepartures(raw, now = Date.now()) {
const departures = [];
const routes = [];
let partial = false;
for (const item of list(record(raw).routes)) {
const route = record(item);
const routeInfo = { id: id(route.id), number: text(route.route_number), name: text(route.name) };
routes.push(routeInfo);
if (route.error) { partial = true; continue; }
for (const value of list(route.arrivals)) {
const arrival = record(value);
const time = typeof arrival.scheduledTime === 'string' && /^(\d{1,2}):([0-5]\d)(?::[0-5]\d)?$/.exec(arrival.scheduledTime);
// RideBus ETA includes historical delay; this view explicitly shows the timetable.
const minutes = Number(arrival.minutesUntil) - Number(arrival.delay ?? 0);
if (!time || Number(time[1]) > 23 || arrival.minutesUntil == null || !Number.isFinite(minutes)) {
partial = true; continue;
}
if (minutes < 0 || minutes >= 1440) continue;
const scheduledAt = new Date(now + minutes * 60000).toISOString();
let serviceDate = new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(0, 10);
const originTime = typeof arrival.trip?.departureTime === 'string' && /^(\d{1,2}):([0-5]\d)/.exec(arrival.trip.departureTime);
if (originTime && Number(time[1]) * 60 + Number(time[2]) < Number(originTime[1]) * 60 + Number(originTime[2])) serviceDate = previousDate(serviceDate);
departures.push({
routeId: routeInfo.id, routeNumber: routeInfo.number, direction: routeInfo.name,
scheduledTime: `${time[1].padStart(2, '0')}:${time[2]}`,
// Use an absolute instant so old snapshots expire, including across midnight.
scheduledAt,
...(arrival.trip ? { tripKey: tripIdentity(arrival.trip, serviceDate) } : {}),
});
}
}
return {
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
updatedAt: new Date(now).toISOString(), partial,
};
}
export function createRideBusClient({
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/',
apiKey = process.env.RIDEBUS_API_KEY,
fetchData = fetch,
now = Date.now,
} = {}) {
const cache = new Map();
const pending = new Map();
const rawCache = new Map();
const rawPending = new Map();
const queue = [];
let active = 0;
async function upstream(path, cityId, allowMissing = false) {
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
if (active >= 6) await new Promise(resolve => queue.push(resolve));
else active++;
const headers = { Accept: 'application/json', 'X-API-Key': apiKey };
if (cityId) headers['X-City-Id'] = String(cityId);
try {
const response = await fetchData(new URL(path, `${baseUrl.replace(/\/+$/, '')}/`), {
headers, redirect: 'error', signal: AbortSignal.timeout(12000),
});
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
return await response.json();
} catch (error) { throw error instanceof ApiError ? error : new ApiError(502); }
finally {
const next = queue.shift();
if (next) next(); else active--;
}
}
async function cachedUpstream(path, cityId, allowMissing = false) {
const key = `${cityId}:${path}`;
const entry = rawCache.get(key);
if (entry && entry.expires > now()) return entry.data;
if (rawPending.has(key)) return rawPending.get(key);
const request = upstream(path, cityId, allowMissing).then(data => {
if (rawCache.size >= 200) rawCache.delete(rawCache.keys().next().value);
rawCache.set(key, { data, expires: now() + 300000 });
return data;
}).finally(() => rawPending.delete(key));
rawPending.set(key, request);
return request;
}
async function timetable(input) {
const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId));
const routes = list(stop.routes).map(route => ({ id: id(route.id), number: text(route.route_number), name: text(route.name) }));
const dates = [previousDate(input.date), input.date];
const results = await Promise.allSettled(routes.map(async route => {
const [stops, ...schedules] = await Promise.all([
cachedUpstream(`api/v1/routes/${route.id}/stops`, input.cityId),
...dates.map(date => cachedUpstream(`api/v1/routes/${route.id}/schedule/today?date=${date}`, input.cityId, true)),
]);
const days = schedules.map((schedule, index) => schedule
? routeDepartures(route, stops, schedule, input.stopId, dates[index]) : { departures: [], partial: false });
return { departures: days.flatMap(day => day.departures).filter(departure =>
new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(0, 10) === input.date),
partial: days.some(day => day.partial), isHoliday: !!schedules[1]?.isHoliday,
holidayName: typeof schedules[1]?.holidayName === 'string' ? schedules[1].holidayName : null };
}));
if (routes.length && results.every(result => result.status === 'rejected')) throw new ApiError(502);
const days = results.filter(result => result.status === 'fulfilled').map(result => result.value);
const departures = days.flatMap(day => day.departures).map(({ trip, serviceDate, ...departure }) => departure);
return {
date: input.date, routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
updatedAt: new Date(now()).toISOString(), partial: days.some(day => day.partial) || days.length < routes.length,
isHoliday: days.some(day => day.isHoliday), holidayName: days.find(day => day.holidayName)?.holidayName ?? null,
};
}
async function journey(input) {
const rawRoute = record(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId));
const route = { id: id(rawRoute.id), number: text(rawRoute.route_number), name: text(rawRoute.name) };
const stops = list(rawRoute.stops).map(stop => ({ ...stop, stop_id: stop.id }));
const date = new Date(Date.parse(input.at) + 10800000).toISOString().slice(0, 10);
const dates = [previousDate(date), date];
const schedules = await Promise.all(dates.map(value => cachedUpstream(`api/v1/routes/${input.routeId}/schedule/today?date=${value}`, input.cityId, true)));
const candidates = schedules.flatMap((schedule, index) => schedule ? routeDepartures(route, stops, schedule, input.stopId, dates[index]).departures : [])
.filter(item => Math.abs(Date.parse(item.scheduledAt) - Date.parse(input.at)) < 60000 && (!input.tripKey || item.tripKey === input.tripKey));
const unique = [...new Map(candidates.map(item => [item.tripKey, item])).values()];
if (!unique.length) throw new ApiError(404, 'journey_not_found');
if (unique.length > 1) throw new ApiError(409, 'journey_ambiguous');
const departure = unique[0];
return { route, stops: journeyStops(stops, departure), selectedStopId: input.stopId, note: departure.note,
updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey };
}
async function load(input) {
if (input.resource === 'timetable') return timetable(input);
if (input.resource === 'journey') return journey(input);
if (input.resource === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId);
if (input.resource === 'nearby') return normalizeNearby(await upstream(`api/v1/stops/nearby?${new URLSearchParams({ lat: String(input.lat), lon: String(input.lon), radius: String(input.radius) })}`, input.cityId));
const path = input.resource === 'cities' ? 'api/v1/cities'
: input.resource === 'stops' ? 'api/v1/stops' : `api/v1/eta/stop/${input.stopId}`;
const raw = await upstream(path, input.cityId);
return input.resource === 'cities' ? normalizeCities(raw)
: input.resource === 'stops' ? normalizeStops(raw) : normalizeDepartures(raw, now());
}
return {
async get(input) {
// Location searches are ephemeral: coordinates and results never enter caches.
if (input.resource === 'nearby') return load(input);
const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`;
const entry = cache.get(key);
if (entry && entry.expires > now()) return entry.data;
if (pending.has(key)) return pending.get(key);
const request = load(input).then(data => {
if (cache.size >= 200) cache.delete(cache.keys().next().value);
const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000;
cache.set(key, { data, expires: now() + ttl });
return data;
}).finally(() => pending.delete(key));
pending.set(key, request);
return request;
},
};
}

87
server/timetable.mjs Normal file
View File

@@ -0,0 +1,87 @@
import { ApiError } from './errors.mjs';
export function validDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const time = Date.parse(`${value}T12:00:00Z`);
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
}
export function previousDate(value) {
return new Date(Date.parse(`${value}T12:00:00Z`) - 86400000).toISOString().slice(0, 10);
}
export function tripIdentity(trip, serviceDate) {
const departureTime = String(trip.departureTime ?? '').split(':');
const time = `${departureTime[0]?.padStart(2, '0')}:${departureTime[1]}:${departureTime[2] || '00'}`;
return Buffer.from(JSON.stringify([serviceDate, time, trip.startStopId || null, trip.endStopId || null,
trip.viaStopIds || [], trip.daysOfWeek || [], trip.exceptDaysOfWeek || [], trip.note || ''])).toString('base64url');
}
function stopMap(rawStops) {
if (!Array.isArray(rawStops)) throw new ApiError(502);
return new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
}
function tripArrival(trip, target, stops, serviceDate) {
const start = trip.startStopId ? stops.get(Number(trip.startStopId)) : undefined;
const end = trip.endStopId ? stops.get(Number(trip.endStopId)) : undefined;
if ((trip.startStopId && !start) || (trip.endStopId && !end)) throw new ApiError(502);
const sequence = Number(target.sequence);
const reverse = start && end && Number(start.sequence) > Number(end.sequence);
if (reverse ? sequence > Number(start.sequence) || sequence < Number(end.sequence)
: (start && sequence < Number(start.sequence)) || (end && sequence > Number(end.sequence))) return null;
const time = typeof trip.departureTime === 'string' && /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(trip.departureTime);
const targetOffset = Number(target.time_offset_minutes ?? 0);
const startOffset = Number(start?.time_offset_minutes ?? 0);
const offset = start && Number(start.sequence) > sequence ? startOffset - targetOffset : targetOffset - startOffset;
if (!time || !Number.isFinite(sequence) || !Number.isFinite(offset) || offset < 0 || offset >= 1440) throw new ApiError(502);
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`)
+ (Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] ?? 0) + offset * 60) * 1000).toISOString();
const local = new Date(Date.parse(scheduledAt) + 10800000).toISOString();
return { scheduledTime: local.slice(11, 16), scheduledAt };
}
export function journeyStops(rawStops, departure) {
const map = stopMap(rawStops);
const result = rawStops.flatMap(stop => {
if (!Number.isSafeInteger(Number(stop.stop_id)) || Number(stop.stop_id) <= 0 || typeof stop.name !== 'string' || !stop.name.trim()) throw new ApiError(502);
const arrival = tripArrival(departure.trip, stop, map, departure.serviceDate);
return arrival ? [{ id: Number(stop.stop_id), sequence: Number(stop.sequence), name: stop.name.trim(), ...arrival }] : [];
});
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.sequence - b.sequence);
}
// RideBus times are local to Belarus (UTC+3). Preserve the actual date when
// a trip reaches a stop after midnight instead of wrapping its clock time.
export function routeDepartures(route, rawStops, rawSchedule, stopId, serviceDate) {
const stops = stopMap(rawStops);
const target = rawStops.find(stop => Number(stop.stop_id) === stopId);
if (!target) throw new ApiError(502);
const schedule = rawSchedule?.schedule;
if (!schedule || typeof schedule !== 'object') throw new ApiError(502);
let trips = schedule.trips;
if (typeof trips === 'string') {
try { trips = JSON.parse(trips); } catch { throw new ApiError(502); }
}
if (!Array.isArray(trips) || trips.length === 0) {
if (!Array.isArray(schedule.departure_times)) throw new ApiError(502);
trips = schedule.departure_times.map(departureTime => ({ departureTime }));
}
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
const departures = [];
let partial = false;
for (const trip of trips) {
if (!trip || typeof trip !== 'object') { partial = true; continue; }
const included = Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [];
const excluded = Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.map(Number) : [];
if ((included.length && !included.includes(weekday)) || excluded.includes(weekday)) continue;
try {
const arrival = tripArrival(trip, target, stops, serviceDate);
if (arrival) departures.push({ routeId: route.id, routeNumber: route.number, direction: route.name,
...arrival, note: typeof trip.note === 'string' ? trip.note.trim() : '',
tripKey: tripIdentity(trip, serviceDate), serviceDate, trip,
});
} catch { partial = true; }
}
return { departures, partial };
}