74 lines
5.0 KiB
JavaScript
74 lines
5.0 KiB
JavaScript
import { ApiError } from './board.mjs';
|
|
import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs';
|
|
|
|
function normalizeSchedules(raw, route, rawStops, stopId) {
|
|
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
|
|
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
|
|
const target = stops.get(stopId);
|
|
if (!target || !Number.isFinite(Number(target.sequence))) throw new ApiError();
|
|
return raw.map(schedule => {
|
|
if (Number(schedule.route_id) !== route.id || !['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.day_type)) throw new ApiError();
|
|
let trips = schedule.trips;
|
|
if (typeof trips === 'string') { try { trips = JSON.parse(trips); } catch { throw new ApiError(); } }
|
|
if (!Array.isArray(trips) || !trips.length) {
|
|
if (!Array.isArray(schedule.departure_times)) throw new ApiError();
|
|
trips = schedule.departure_times.map(departureTime => ({ departureTime }));
|
|
}
|
|
const normalized = trips.flatMap(trip => {
|
|
const time = typeof trip?.departureTime === 'string' && /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(trip.departureTime);
|
|
if (!time) throw new ApiError();
|
|
const start = trip.startStopId ? stops.get(Number(trip.startStopId)) : null;
|
|
const end = trip.endStopId ? stops.get(Number(trip.endStopId)) : null;
|
|
if (trip.startStopId && !start || trip.endStopId && !end) throw new ApiError();
|
|
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 [];
|
|
const offset = start && Number(start.sequence) > sequence ? Number(start.time_offset_minutes || 0) - Number(target.time_offset_minutes || 0)
|
|
: Number(target.time_offset_minutes || 0) - Number(start?.time_offset_minutes || 0);
|
|
if (!Number.isFinite(offset) || offset < 0 || offset >= 1440) throw new ApiError();
|
|
const days = value => {
|
|
if (value == null) return [];
|
|
if (!Array.isArray(value) || value.some(day => !Number.isInteger(Number(day)) || Number(day) < 1 || Number(day) > 7)) throw new ApiError();
|
|
return [...new Set(value.map(Number))];
|
|
};
|
|
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
|
|
return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60,
|
|
destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name,
|
|
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
|
|
});
|
|
const date = value => {
|
|
if (value == null) return null;
|
|
const result = String(value).slice(0, 10);
|
|
if (!validDate(result)) throw new ApiError();
|
|
return result;
|
|
};
|
|
return { dayType: schedule.day_type, validFrom: date(schedule.valid_from), validUntil: date(schedule.valid_until), trips: normalized };
|
|
});
|
|
}
|
|
|
|
export async function loadTimetable({ upstream, cityId, cityName, stop, now }) {
|
|
if (!Array.isArray(stop.routes)) throw new ApiError();
|
|
const date = localDate(now);
|
|
const calendarYears = [...new Set([shiftDate(date, -1), shiftDate(date, 8)].map(value => Number(value.slice(0, 4))))];
|
|
const calendars = await Promise.all(calendarYears.map(year => upstream(`api/v1/holidays/year/${year}`)));
|
|
const holidays = calendars.flatMap(raw => {
|
|
if (!Array.isArray(raw)) throw new ApiError();
|
|
return raw.map(item => { const date = String(item.date).slice(0, 10); if (!validDate(date)) throw new ApiError(); return date; });
|
|
});
|
|
const routes = stop.routes.filter(route => route.is_active !== false).map(route => ({ id: Number(route.id), number: route.route_number?.trim(), name: route.name?.trim(), type: route.type === 'minibus' ? 'minibus' : 'bus' }));
|
|
if (routes.some(route => !Number.isSafeInteger(route.id) || !route.number || !route.name)) throw new ApiError();
|
|
// Bound fan-out. Publish only a complete bundle; one failed route retains the previous client cache.
|
|
const result = new Array(routes.length);
|
|
let index = 0;
|
|
await Promise.all(Array.from({ length: Math.min(4, routes.length) }, async () => {
|
|
while (index < routes.length) {
|
|
const current = index++, route = routes[current];
|
|
const [schedules, stops] = await Promise.all([upstream(`api/v1/routes/${route.id}/schedules`), upstream(`api/v1/routes/${route.id}/stops`)]);
|
|
result[current] = { ...route, schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) };
|
|
}
|
|
}));
|
|
return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' },
|
|
routes: result, holidays: [...new Set(holidays)], calendarYears, updatedAt: new Date(now).toISOString(), expiresAt: new Date(now + TIMETABLE_MAX_MS).toISOString() };
|
|
}
|