Files
ridebus-board/server/timetable.mjs
Zakhar Timoshenko 5c69f6691d feat: implement night mode functionality with UI updates and journey tracking
- Added NightScreen component for displaying night mode information and alerts.
- Introduced NextStops component to show upcoming stops during a journey.
- Implemented logic for determining night mode activation based on schedule and user settings.
- Created journey utility functions to manage onward journey data and stop information.
- Updated tests to cover new night mode features and journey logic.
- Added fixture for testing night mode scenarios with specific timestamps.
- Removed QR code from night screen for a cleaner UI experience.
- Enhanced admin settings to manage night mode configurations.
2026-10-11 05:04:00 +03:00

89 lines
6.2 KiB
JavaScript

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