// 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 };