Files
ridebus-board/server/board.mjs
Zakhar Timoshenko e3074555a7 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.
2026-10-09 23:55:47 +03:00

157 lines
9.8 KiB
JavaScript

import { loadTimetable } from './timetable.mjs';
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
import { 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; }
}
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 function scheduledInstant(time, 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`;
}
function routeInfo(route) {
return { id: Number(route.id), number: clean(route.route_number), name: clean(route.name), type: route.type === 'minibus' ? 'minibus' : 'bus' };
}
export function normalizeDepartures(raw, now, stopNames = new Map()) {
if (!raw || !Array.isArray(raw.routes)) throw new ApiError();
let partial = false;
const departures = [];
const routes = [];
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);
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);
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),
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
source: 'schedule' });
}
}
return { departures: [...new Map(departures.map(item => [item.id, item])).values()].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
}
export function normalizeAlerts(raw, routes, cityId, now, stops = []) {
if (!Array.isArray(raw)) throw new ApiError();
const routeIds = new Set(routes.map(route => route.id));
const routeNumbers = new Set(routes.map(route => route.number));
return [...new Map(raw.filter(alert => {
if (!alert || alert.is_active === false || alert.city_id != null && Number(alert.city_id) !== cityId) return false;
const start = alert.start_time == null ? null : Date.parse(alert.start_time);
const end = alert.end_time == null ? null : Date.parse(alert.end_time);
if (start !== null && (!Number.isFinite(start) || start > now) || end !== null && (!Number.isFinite(end) || end <= now)) return false;
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
const numbers = strings(alert.route_numbers);
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
return targets.length === 0 && numbers.length === 0 || targets.some(id => routeIds.has(id)) || numbers.some(number => routeNumbers.has(number));
}).map(alert => {
const numbers = strings(alert.route_numbers);
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
if (!numbers.length) numbers.push(...routes.filter(route => targets.includes(route.id)).map(route => route.number));
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
startAt: alert.start_time || null, endAt: alert.end_time || null };
if (value.type === 'cancellation') value.cancellations = parseCancellationNotice(value.message, value.routeNumbers, stops);
return [value.id, value];
})).values()];
}
export function createBoardClient({
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', apiKey = process.env.RIDEBUS_API_KEY,
cityId = Number(process.env.BOARD_CITY_ID || 1), cityName = process.env.BOARD_CITY_NAME || 'Полоцк',
stopName = process.env.BOARD_STOP_NAME || 'Швейная фабрика', defaultStopId = Number(process.env.BOARD_STOP_ID || 1),
passengerBaseUrl = process.env.BOARD_PWA_URL || 'https://pwa.ridebus.by/',
fetchData = fetch, now = Date.now,
} = {}) {
const cache = new Map();
const pending = new Map();
const url = new URL(baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
if (!['https:', 'http:'].includes(url.protocol) || !Number.isSafeInteger(cityId) || cityId < 1) throw new Error('Invalid RideBus configuration');
async function upstream(path) {
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
try {
const response = await fetchData(new URL(path, url), { headers: { Accept: 'application/json', 'X-API-Key': apiKey, 'X-City-Id': String(cityId) },
redirect: 'error', signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
return await response.json();
} catch (error) { throw error instanceof ApiError ? error : new ApiError(); }
}
function cached(key, ttl, load) {
const entry = cache.get(key);
if (entry?.expires > now()) return Promise.resolve(entry.data);
if (pending.has(key)) return pending.get(key);
const request = load().then(data => { cache.set(key, { expires: now() + ttl, data }); return data; }).finally(() => pending.delete(key));
pending.set(key, request);
return request;
}
function catalog() {
return cached('catalog', 300000, async () => {
const raw = await upstream('api/v1/stops');
if (!Array.isArray(raw)) throw new ApiError();
return raw.filter(stop => Number(stop.id) > 0 && clean(stop.name));
});
}
async function stopDetails(stopId) {
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
if (!(await catalog()).some(stop => Number(stop.id) === stopId)) throw new ApiError(404, 'stop_not_found');
const stop = await cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`));
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
return stop;
}
const config = () => ({ cityId, cityName, stopName, defaultStopId, configured: Boolean(apiKey?.trim()), timeZone: 'Europe/Minsk', refreshSeconds: 20, passengerBaseUrl });
return {
config,
async coordinates(stopId) { const stop = await stopDetails(stopId); return { latitude: stop.latitude, longitude: stop.longitude }; },
async timetable(stopId) {
const stop = await stopDetails(stopId);
return cached(`timetable:${stopId}`, 1800000, () => loadTimetable({ upstream, cityId, cityName, stop, now: now() }));
},
async stops() { return (await catalog()).map(stop => ({ id: Number(stop.id), name: clean(stop.name), direction: clean(stop.description) })); },
async board(stopId) {
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
const stops = await catalog();
const catalogStop = stops.find(stop => Number(stop.id) === stopId);
if (!catalogStop) throw new ApiError(404, 'stop_not_found');
return cached(`board:${stopId}`, 12000, async () => {
const [stop, eta] = await Promise.all([
cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`)), upstream(`api/v1/eta/stop/${stopId}`),
]);
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
const generatedAt = now();
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
let alerts = [], alertsAvailable = true;
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now(), stops); }
catch { alertsAvailable = false; }
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };
});
},
};
}