import { ApiError } from './board.mjs'; import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs'; function normalizeStops(raw) { if (!Array.isArray(raw)) return []; const stops = raw.map(stop => { const offset = stop.time_offset_minutes == null || stop.time_offset_minutes === '' ? NaN : Number(stop.time_offset_minutes); return { id: Number(stop.stop_id), name: typeof stop.name === 'string' ? stop.name.trim() : '', sequence: Number(stop.sequence), offsetMinutes: Number.isFinite(offset) && offset >= 0 && offset < 1440 ? offset : null }; }); // An ambiguous loop or incomplete stop identity cannot produce a reliable diagram. if (stops.some(stop => !Number.isSafeInteger(stop.id) || stop.id < 1 || !stop.name || !Number.isSafeInteger(stop.sequence) || stop.sequence < 0) || new Set(stops.map(stop => stop.id)).size !== stops.length || new Set(stops.map(stop => stop.sequence)).size !== stops.length) return []; return stops.sort((a, b) => a.sequence - b.sequence); } 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]; const origin = start || [...rawStops].sort((a, b) => Number(a.sequence) - Number(b.sequence))[0]; return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60, originStopId: Number(origin?.stop_id), originTime: `${time[1].padStart(2, '0')}:${time[2]}`, endStopId: Number(terminal?.stop_id), 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, stops: normalizeStops(stops), 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() }; }