Files
The-transit-Project/transit_api/lib/schedule.js
T
Sven laptop 7d55580d87 Refactor transit_api into a repo/routes module split
Split the 5,441-line index.js into an 86-line bootstrap, thin domain
route modules, shared lib/ helpers, and a repo/ layer that isolates the
DEV_MOCK branches (repo/*.mock.js). Repo methods return {statusCode, body};
routes are one-line wrappers. Pin search_path to "transit.bus" and
document the /bus/ prefix with legacy root aliases (Deprecation header).

Parity verified: 57/57 byte-identical on the stub differential harness
plus 24/24 live read-only responses vs the original against real
PostgreSQL. The only intentional behavior change: mock reorder/delete
bus-line endpoints no longer require a live DB (previously 500 in
DEV_MOCK=1 without PG). DEV_MOCK gate and node --check clean.
2026-09-24 20:18:32 +02:00

607 lines
17 KiB
JavaScript

// Pure schedule/conflict helpers (moved from index.js — do not add db or http deps).
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Calculate departure times for each stop in a route
* Returns array of { order, stopName, departureTime }
*/
function calculateDepartureTimes(startTime, busLineId, mock) {
const startDate = new Date(startTime);
// Get route stops for this bus line
const routeStops = mock.routeStops
.filter((rs) => rs.busLineId === busLineId)
.sort((a, b) => a.stopOrder - b.stopOrder);
return routeStops.map((rs) => {
const departureDate = new Date(startDate);
// Add estimated arrival minutes to get departure time
departureDate.setMinutes(
departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0),
);
const stop = mock.stops.find((s) => s.id === rs.stopId);
return {
order: rs.stopOrder,
stopId: rs.stopId,
stopName: stop?.name || "Unknown Stop",
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
departureTime: departureDate.toISOString(),
};
});
}
function parseWeekdaysValue(value) {
if (!value) return [];
if (Array.isArray(value))
return value.map((v) => Number(v)).filter((v) => Number.isFinite(v));
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((v) => Number(v)).filter((v) => Number.isFinite(v));
}
} catch (_) {
// Fall through to CSV/PG-array parsing.
}
// Handles values like "1,2,3" or "{1,2,3}".
const normalized = trimmed.replace("{", "").replace("}", "");
return normalized
.split(",")
.map((v) => Number(v.trim()))
.filter((v) => Number.isFinite(v));
}
return [];
}
function toNumberOrNull(value) {
if (value === undefined || value === null || value === "") return null;
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
function parseCommaSeparatedValues(value) {
if (!value && value !== 0) return [];
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function toDateKey(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
function expandRecurringScheduleRows(rows) {
const expandedRows = [];
for (const row of rows) {
const weekdays = parseWeekdaysValue(row.weekdays);
if (!weekdays || weekdays.length === 0) {
expandedRows.push(row);
continue;
}
const templateDate = new Date(row.start_time);
if (Number.isNaN(templateDate.getTime())) {
expandedRows.push(row);
continue;
}
const templateDayOfWeek = templateDate.getDay();
const sortedWeekdays = [...new Set(weekdays)].sort((a, b) => a - b);
const durationMinutes = Number(
row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60,
);
for (let week = 0; week < 52; week++) {
for (const targetWeekday of sortedWeekdays) {
const daysOffset = (targetWeekday - templateDayOfWeek + 7) % 7;
const totalDays = week * 7 + daysOffset;
const scheduleDate = new Date(templateDate);
scheduleDate.setDate(scheduleDate.getDate() + totalDays);
const endDate = new Date(scheduleDate);
endDate.setMinutes(endDate.getMinutes() + durationMinutes);
expandedRows.push({
...row,
start_time: scheduleDate.toISOString(),
end_time: endDate.toISOString(),
is_recurring_instance: true,
template_id: row.id,
});
}
}
}
return expandedRows;
}
function mapAdminScheduleRow(row, routeStopsByLine = new Map()) {
const weekdays = parseWeekdaysValue(row.weekdays);
const busLineId = Number(row.busLineId ?? row.bus_line_id);
const startTime = row.startTime ?? row.start_time;
const routeStops = routeStopsByLine.get(busLineId) || [];
const departureTimes = buildDepartureTimes(startTime, routeStops);
return {
id: Number(row.id),
busLineId,
busId: Number(row.busId ?? row.bus_id),
driverId:
row.driverId !== null && row.driverId !== undefined
? Number(row.driverId)
: null,
driverName: row.driverName ?? row.driver_name ?? null,
startTime,
endTime: row.endTime ?? row.end_time,
weekdays,
departureTimes,
status: row.status,
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
busName: row.busName ?? row.bus_name ?? "Unknown Bus",
licensePlate: row.licensePlate ?? row.license_plate ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
isRecurringInstance: row.is_recurring_instance || false,
templateId: row.template_id || row.templateId || null,
recurrenceLabel: formatWeekdayLabel(weekdays),
isRecurring: weekdays.length > 0,
};
}
function scheduleMatchesFilters(
schedule,
filters,
routeStopsByLine = new Map(),
) {
if (!schedule || !filters) return true;
const scheduleBusLineId = Number(schedule.bus_line_id ?? schedule.busLineId);
const scheduleBusId = Number(schedule.bus_id ?? schedule.busId);
const scheduleDriverId = Number(schedule.driver_id ?? schedule.driverId);
const scheduleStatus = String(schedule.status || "");
const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime);
if (filters.busLineId !== null && scheduleBusLineId !== filters.busLineId) {
return false;
}
if (filters.busId !== null && scheduleBusId !== filters.busId) {
return false;
}
if (filters.driverId !== null && scheduleDriverId !== filters.driverId) {
return false;
}
if (
filters.statuses.length > 0 &&
!filters.statuses.includes(scheduleStatus)
) {
return false;
}
if (filters.date && scheduleDateKey !== filters.date) {
return false;
}
if (
filters.fromDate &&
scheduleDateKey &&
scheduleDateKey < filters.fromDate
) {
return false;
}
if (filters.toDate && scheduleDateKey && scheduleDateKey > filters.toDate) {
return false;
}
if (filters.stopId !== null) {
const routeStops = routeStopsByLine.get(scheduleBusLineId) || [];
const matchesStop = routeStops.some(
(routeStop) =>
Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId,
);
if (!matchesStop) {
return false;
}
}
return true;
}
function parseScheduleFilters(query, options = {}) {
const statuses = parseCommaSeparatedValues(
query.status || query.statuses || options.statuses || [],
);
return {
date: query.date ? String(query.date).trim() : null,
fromDate: query.fromDate ? String(query.fromDate).trim() : null,
toDate: query.toDate ? String(query.toDate).trim() : null,
driverId: toNumberOrNull(
query.driverId ??
query.driver_id ??
query.chauffeurId ??
query.chauffeur_id,
),
busId: toNumberOrNull(query.busId ?? query.bus_id),
busLineId: toNumberOrNull(
query.busLineId ?? query.lineId ?? query.bus_line_id,
),
stopId: toNumberOrNull(
query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id,
),
statuses,
};
}
function formatWeekdayLabel(weekdays) {
const labels = ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"];
if (!Array.isArray(weekdays) || weekdays.length === 0) {
return "Eenmalig";
}
if (weekdays.length === 7) {
return "Dagelijks";
}
return [...new Set(weekdays)]
.sort((a, b) => a - b)
.filter((day) => day >= 0 && day < labels.length)
.map((day) => labels[day])
.join(", ");
}
function buildDepartureTimes(startTime, routeStops = []) {
const startDate = new Date(startTime);
return routeStops.map((routeStop) => {
const departureDate = new Date(startDate);
const etaMinutes =
Number(
routeStop.estimated_arrival_minutes ??
routeStop.estimatedArrivalMinutes,
) || 0;
departureDate.setMinutes(departureDate.getMinutes() + etaMinutes);
return {
order: Number(routeStop.stop_order ?? routeStop.stopOrder),
stopId: Number(routeStop.stop_id ?? routeStop.stopId),
stopName: routeStop.stop_name || routeStop.name || "Unknown Stop",
estimatedArrivalMinutes: etaMinutes,
departureTime: departureDate.toISOString(),
};
});
}
function isRecurringSchedule(schedule) {
return parseWeekdaysValue(schedule.weekdays).length > 0;
}
function scheduleOccursOnDate(schedule, dateKey) {
if (!dateKey) return true;
const weekdays = parseWeekdaysValue(schedule.weekdays);
const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime);
if (!scheduleDateKey) {
return false;
}
if (weekdays.length === 0) {
return scheduleDateKey === dateKey;
}
if (scheduleDateKey > dateKey) {
return false;
}
const targetDate = new Date(`${dateKey}T00:00:00`);
return weekdays.includes(targetDate.getDay());
}
function materializeScheduleOverviewRow(
row,
dateKey,
routeStopsByLine = new Map(),
) {
const weekdays = parseWeekdaysValue(row.weekdays);
const recurring = weekdays.length > 0;
const templateStart = new Date(row.startTime ?? row.start_time);
const templateEnd = new Date(row.endTime ?? row.end_time);
let startTime = row.startTime ?? row.start_time;
let endTime = row.endTime ?? row.end_time;
if (recurring && dateKey) {
const occurrenceStart = new Date(`${dateKey}T00:00:00`);
occurrenceStart.setHours(
templateStart.getHours(),
templateStart.getMinutes(),
templateStart.getSeconds(),
templateStart.getMilliseconds(),
);
startTime = occurrenceStart.toISOString();
if (
Number.isFinite(templateEnd.getTime()) &&
Number.isFinite(templateStart.getTime())
) {
const durationMillis = templateEnd.getTime() - templateStart.getTime();
const occurrenceEnd = new Date(occurrenceStart);
occurrenceEnd.setMilliseconds(
occurrenceEnd.getMilliseconds() + durationMillis,
);
endTime = occurrenceEnd.toISOString();
}
}
const busLineId = Number(row.busLineId ?? row.bus_line_id);
const routeStops = routeStopsByLine.get(busLineId) || [];
const departureTimes = buildDepartureTimes(startTime, routeStops);
return {
id: Number(row.id),
busLineId,
busId: Number(row.busId ?? row.bus_id),
// Hide driver information in public schedule materialization for privacy
driverId: null,
driverName: null,
startTime,
endTime,
weekdays,
status: row.status,
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
busName: row.busName ?? row.bus_name ?? "Unknown Bus",
licensePlate: row.licensePlate ?? row.license_plate ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
scheduleType: recurring ? "recurring" : "one_time",
recurrenceLabel: formatWeekdayLabel(weekdays),
isRecurringInstance: recurring && Boolean(dateKey),
templateId: row.templateId ?? row.template_id ?? null,
departureTimes,
};
}
function mapRecurringOccurrenceRows(rows, dateKey, routeStopsByLine) {
return rows
.filter((row) => scheduleOccursOnDate(row, dateKey))
.map((row) =>
materializeScheduleOverviewRow(row, dateKey, routeStopsByLine),
)
.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
}
function materializeScheduleRowsInRange(
rows,
fromDateKey,
toDateKey,
routeStopsByLine,
) {
const occurrences = [];
if (!fromDateKey || !toDateKey) return occurrences;
const fromDate = new Date(`${fromDateKey}T00:00:00`);
const toDate = new Date(`${toDateKey}T23:59:59`);
// Safety cap: don't generate more than 366 days
const maxDays = 366;
const spanDays = Math.ceil((toDate - fromDate) / (24 * 60 * 60 * 1000));
if (spanDays > maxDays) return occurrences;
for (const row of rows) {
const weekdays = parseWeekdaysValue(row.weekdays);
if (!weekdays || weekdays.length === 0) {
const rowDateKey = toDateKey(row.start_time ?? row.startTime);
if (rowDateKey && rowDateKey >= fromDateKey && rowDateKey <= toDateKey) {
occurrences.push(
materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine),
);
}
continue;
}
// recurring template: generate occurrences per weekday within range
const templateStart = new Date(row.start_time ?? row.startTime);
for (const wd of [...new Set(weekdays)]) {
// find first date >= fromDate that matches weekday wd and is >= templateStart
let cursor = new Date(fromDate);
const fromWeekday = cursor.getDay();
const daysOffset = (wd - fromWeekday + 7) % 7;
cursor.setDate(cursor.getDate() + daysOffset);
// ensure cursor >= templateStart (so recurrence doesn't start before template)
if (cursor < templateStart) {
// advance by weeks until >= templateStart
const diffDays = Math.ceil(
(templateStart - cursor) / (24 * 60 * 60 * 1000),
);
const weeksToAdvance = Math.ceil(diffDays / 7);
cursor.setDate(cursor.getDate() + weeksToAdvance * 7);
}
while (cursor <= toDate) {
const occurrenceKey = cursor.toISOString().slice(0, 10);
occurrences.push(
materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine),
);
cursor.setDate(cursor.getDate() + 7);
}
}
}
// sort and return
return occurrences.sort(
(a, b) => new Date(a.startTime) - new Date(b.startTime),
);
}
/**
* Check if two schedules have date/weekday overlap
*/
function doSchedulesDateOverlap(
schedule1Start,
schedule1End,
schedule1Weekdays,
schedule2Start,
schedule2End,
schedule2Weekdays,
) {
const s1Start = new Date(schedule1Start);
const s1End = new Date(schedule1End);
const s2Start = new Date(schedule2Start);
const s2End = new Date(schedule2End);
// Check time overlap
if (s1End <= s2Start || s2End <= s1Start) {
return false; // No time overlap
}
// If both have no weekdays (one-time), check if same day
if (
(!schedule1Weekdays || schedule1Weekdays.length === 0) &&
(!schedule2Weekdays || schedule2Weekdays.length === 0)
) {
return s1Start.toDateString() === s2Start.toDateString();
}
// If one has weekdays and other doesn't, check if one-time falls on weekday
if (
schedule1Weekdays &&
schedule1Weekdays.length > 0 &&
(!schedule2Weekdays || schedule2Weekdays.length === 0)
) {
const s2Day = s2Start.getDay(); // 0=Sun, 1=Mon, etc.
const s2Adjusted = s2Day === 0 ? 0 : s2Day; // Normalize to 0-6
return schedule1Weekdays.includes(s2Adjusted);
}
if (
(!schedule1Weekdays || schedule1Weekdays.length === 0) &&
schedule2Weekdays &&
schedule2Weekdays.length > 0
) {
const s1Day = s1Start.getDay();
const s1Adjusted = s1Day === 0 ? 0 : s1Day;
return schedule2Weekdays.includes(s1Adjusted);
}
// Both have weekdays: check if any day overlaps
if (schedule1Weekdays && schedule2Weekdays) {
return schedule1Weekdays.some((d) => schedule2Weekdays.includes(d));
}
return false;
}
/**
* Check if a driver has conflicts with existing schedules
*/
function checkDriverConflict(
driverId,
startTime,
endTime,
weekdays,
allSchedules,
excludeScheduleId = null,
) {
if (!driverId) return false; // No conflict if no driver specified
return allSchedules.some((s) => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sDriverId = s.driverId ?? s.driver_id;
if (Number(sDriverId) !== Number(driverId)) return false;
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
return doSchedulesDateOverlap(
startTime,
endTime,
wkdays,
sStart,
sEnd,
sWeekdays,
);
});
}
/**
* Check if a bus has conflicts with existing schedules
*/
function checkBusConflict(
busId,
startTime,
endTime,
weekdays,
allSchedules,
excludeScheduleId = null,
) {
if (!busId) return false;
return allSchedules.some((s) => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sBusId = s.busId ?? s.bus_id;
if (Number(sBusId) !== Number(busId)) return false;
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
return doSchedulesDateOverlap(
startTime,
endTime,
wkdays,
sStart,
sEnd,
sWeekdays,
);
});
}
module.exports = { calculateDepartureTimes, parseWeekdaysValue, toNumberOrNull, parseCommaSeparatedValues, toDateKey, expandRecurringScheduleRows, mapAdminScheduleRow, scheduleMatchesFilters, parseScheduleFilters, formatWeekdayLabel, buildDepartureTimes, isRecurringSchedule, scheduleOccursOnDate, materializeScheduleOverviewRow, mapRecurringOccurrenceRows, materializeScheduleRowsInRange, doSchedulesDateOverlap, checkDriverConflict, checkBusConflict };