initial commit
This commit is contained in:
7
server/errors.mjs
Normal file
7
server/errors.mjs
Normal 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
37
server/features.mjs
Normal 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
84
server/index.mjs
Normal 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
236
server/ridebus.mjs
Normal 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
87
server/timetable.mjs
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user