7d55580d87
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.
607 lines
17 KiB
JavaScript
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 };
|