feat: add service window component and related functionality

- Introduced ServiceWindow component to display the last departure today and the first tomorrow.
- Implemented cancellation notice parsing to handle route cancellations effectively.
- Enhanced departure display logic to include cancellation alerts and status updates.
- Created information blocks for displaying service information, weather, and QR codes.
- Added tests for departure features, information handling, and service window functionality.
- Updated fixtures for testing various scenarios including cancellations and long wait times.
This commit is contained in:
2026-10-09 23:55:47 +03:00
parent 046e3c6a2f
commit e3074555a7
44 changed files with 601 additions and 62 deletions

View File

@@ -1,4 +1,6 @@
import { loadTimetable } from './timetable.mjs';
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
import { shiftDate } from '../src/lib/timetable.mjs';
export class ApiError extends Error {
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
@@ -38,8 +40,11 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
const originTime = scheduledInstant(arrival.trip?.departureTime, now)?.slice(11, 16);
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note,
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? shiftDate(localDate(now), -1) : localDate(now),
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
source: 'schedule' });
}
}
@@ -47,7 +52,7 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
}
export function normalizeAlerts(raw, routes, cityId, now) {
export function normalizeAlerts(raw, routes, cityId, now, stops = []) {
if (!Array.isArray(raw)) throw new ApiError();
const routeIds = new Set(routes.map(route => route.id));
const routeNumbers = new Set(routes.map(route => route.number));
@@ -66,10 +71,12 @@ export function normalizeAlerts(raw, routes, cityId, now) {
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
if (!numbers.length) numbers.push(...routes.filter(route => targets.includes(route.id)).map(route => route.number));
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
startAt: alert.start_time || null, endAt: alert.end_time || null };
if (value.type === 'cancellation') value.cancellations = parseCancellationNotice(value.message, value.routeNumbers, stops);
return [value.id, value];
})).values()];
}
@@ -139,7 +146,7 @@ export function createBoardClient({
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
let alerts = [], alertsAvailable = true;
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); }
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now(), stops); }
catch { alertsAvailable = false; }
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };

View File

@@ -33,7 +33,9 @@ function normalizeSchedules(raw, route, rawStops, stopId) {
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]}`,
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) }];
});