Files
ridebus-pwa/server/timetable.mjs
2026-10-07 01:48:32 +03:00

88 lines
4.8 KiB
JavaScript

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 };
}