Support schedule trip variants
This commit is contained in:
26
backend/migrations/006_schedule_trip_variants.sql
Normal file
26
backend/migrations/006_schedule_trip_variants.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Per-departure metadata for partial, via-stop, and calendar-specific trips.
|
||||
-- Existing clients can continue to use departure_times; trips is the richer source.
|
||||
ALTER TABLE schedules
|
||||
ADD COLUMN IF NOT EXISTS trips JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
UPDATE schedules
|
||||
SET trips = (
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'departureTime', to_char(departure_time, 'HH24:MI:SS'),
|
||||
'startStopId', NULL,
|
||||
'endStopId', NULL,
|
||||
'viaStopIds', '[]'::jsonb,
|
||||
'daysOfWeek', '[]'::jsonb,
|
||||
'exceptDaysOfWeek', '[]'::jsonb,
|
||||
'note', ''
|
||||
)
|
||||
ORDER BY departure_time
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM unnest(departure_times) AS expanded_departures(departure_time)
|
||||
)
|
||||
WHERE trips = '[]'::jsonb
|
||||
AND departure_times IS NOT NULL;
|
||||
@@ -23,7 +23,7 @@ class EtaController {
|
||||
|
||||
// Get schedule for this route
|
||||
let scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
SELECT departure_times, trips
|
||||
FROM schedules
|
||||
WHERE route_id = $1
|
||||
AND day_type = $2
|
||||
@@ -36,7 +36,7 @@ class EtaController {
|
||||
if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') {
|
||||
scheduleType = 'sunday';
|
||||
scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
SELECT departure_times, trips
|
||||
FROM schedules
|
||||
WHERE route_id = $1
|
||||
AND day_type = 'sunday'
|
||||
@@ -54,60 +54,65 @@ class EtaController {
|
||||
});
|
||||
}
|
||||
|
||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
||||
|
||||
// Get time offset for this stop
|
||||
const stopResult = await pool.query(`
|
||||
SELECT time_offset_minutes
|
||||
// Get stops for this route so partial trips can be filtered by sequence.
|
||||
const stopsResult = await pool.query(`
|
||||
SELECT stop_id, sequence, time_offset_minutes
|
||||
FROM route_stops
|
||||
WHERE route_id = $1 AND stop_id = $2
|
||||
`, [routeId, stopId]);
|
||||
WHERE route_id = $1
|
||||
ORDER BY sequence
|
||||
`, [routeId]);
|
||||
|
||||
if (stopResult.rows.length === 0) {
|
||||
const routeStops = stopsResult.rows;
|
||||
const targetStop = routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
|
||||
|
||||
if (!targetStop) {
|
||||
return res.status(404).json({
|
||||
error: 'Stop not found on route',
|
||||
message: 'This stop is not part of this route'
|
||||
});
|
||||
}
|
||||
|
||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
||||
const stopsById = this.buildStopsById(routeStops);
|
||||
const trips = this.getScheduleTrips(scheduleResult.rows[0]);
|
||||
const activeTrips = trips.filter(trip => (
|
||||
this.isTripActiveOnDate(trip, currentTime) &&
|
||||
this.tripServesStop(trip, targetStop, stopsById)
|
||||
));
|
||||
|
||||
// Calculate ETAs
|
||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||
const etas = [];
|
||||
|
||||
for (const departureTime of departureTimes) {
|
||||
if (departureTime > currentTimeStr) {
|
||||
// Add time offset to get arrival time at this stop
|
||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
||||
|
||||
// Get historical delay for this hour and day type
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
scheduleType === 'holiday' ? 'sunday' : scheduleType
|
||||
);
|
||||
for (const trip of activeTrips) {
|
||||
const arrival = this.getTripArrivalTime(trip, targetStop, stopsById);
|
||||
if (!arrival) continue;
|
||||
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
scheduleType === 'holiday' ? 'sunday' : scheduleType
|
||||
);
|
||||
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay
|
||||
});
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
if (minutesUntil < 0) continue;
|
||||
|
||||
// Return next 3 arrivals
|
||||
if (etas.length >= 3) break;
|
||||
}
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay,
|
||||
trip: this.publicTripInfo(trip)
|
||||
});
|
||||
}
|
||||
|
||||
etas.sort((a, b) => a.minutesUntil - b.minutesUntil);
|
||||
|
||||
if (etas.length === 0) {
|
||||
return res.json({
|
||||
message: 'No more arrivals today',
|
||||
nextDay: departureTimes[0]
|
||||
nextDay: activeTrips[0]?.departureTime || null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ class EtaController {
|
||||
stopId,
|
||||
currentTime: currentTimeStr,
|
||||
dayType: scheduleType,
|
||||
arrivals: etas
|
||||
arrivals: etas.slice(0, 3)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -170,10 +175,19 @@ class EtaController {
|
||||
// Helper: Calculate ETA for a specific route (internal use)
|
||||
async calculateEtaForRoute(stopId, routeId) {
|
||||
const currentTime = new Date();
|
||||
const dayType = this.getDayType(currentTime);
|
||||
let dayType = this.getDayType(currentTime);
|
||||
|
||||
try {
|
||||
const typeResult = await pool.query(
|
||||
'SELECT get_schedule_type(CURRENT_DATE) as schedule_type'
|
||||
);
|
||||
dayType = typeResult.rows[0].schedule_type;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching SQL schedule type:', error);
|
||||
}
|
||||
|
||||
const scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
let scheduleResult = await pool.query(`
|
||||
SELECT departure_times, trips
|
||||
FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
@@ -181,47 +195,66 @@ class EtaController {
|
||||
LIMIT 1
|
||||
`, [routeId, dayType]);
|
||||
|
||||
if (scheduleResult.rows.length === 0 && dayType === 'holiday') {
|
||||
dayType = 'sunday';
|
||||
scheduleResult = await pool.query(`
|
||||
SELECT departure_times, trips
|
||||
FROM schedules
|
||||
WHERE route_id = $1 AND day_type = 'sunday'
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
LIMIT 1
|
||||
`, [routeId]);
|
||||
}
|
||||
|
||||
if (scheduleResult.rows.length === 0) return [];
|
||||
|
||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
||||
|
||||
const stopResult = await pool.query(`
|
||||
SELECT time_offset_minutes
|
||||
const stopsResult = await pool.query(`
|
||||
SELECT stop_id, sequence, time_offset_minutes
|
||||
FROM route_stops
|
||||
WHERE route_id = $1 AND stop_id = $2
|
||||
`, [routeId, stopId]);
|
||||
WHERE route_id = $1
|
||||
ORDER BY sequence
|
||||
`, [routeId]);
|
||||
|
||||
if (stopResult.rows.length === 0) return [];
|
||||
const routeStops = stopsResult.rows;
|
||||
const targetStop = routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
|
||||
if (!targetStop) return [];
|
||||
|
||||
const stopsById = this.buildStopsById(routeStops);
|
||||
const trips = this.getScheduleTrips(scheduleResult.rows[0]);
|
||||
const activeTrips = trips.filter(trip => (
|
||||
this.isTripActiveOnDate(trip, currentTime) &&
|
||||
this.tripServesStop(trip, targetStop, stopsById)
|
||||
));
|
||||
|
||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||
const etas = [];
|
||||
|
||||
for (const departureTime of departureTimes) {
|
||||
if (departureTime > currentTimeStr) {
|
||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
dayType
|
||||
);
|
||||
for (const trip of activeTrips) {
|
||||
const arrival = this.getTripArrivalTime(trip, targetStop, stopsById);
|
||||
if (!arrival) continue;
|
||||
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
dayType
|
||||
);
|
||||
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay
|
||||
});
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
if (minutesUntil < 0) continue;
|
||||
|
||||
if (etas.length >= 3) break;
|
||||
}
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay,
|
||||
trip: this.publicTripInfo(trip)
|
||||
});
|
||||
}
|
||||
|
||||
return etas;
|
||||
return etas.sort((a, b) => a.minutesUntil - b.minutesUntil).slice(0, 3);
|
||||
}
|
||||
|
||||
// Helper: Get historical delay
|
||||
@@ -255,8 +288,9 @@ class EtaController {
|
||||
addMinutesToTime(timeStr, minutes) {
|
||||
const [hours, mins, secs] = timeStr.split(':').map(Number);
|
||||
const totalMins = hours * 60 + mins + minutes;
|
||||
const newHours = Math.floor(totalMins / 60) % 24;
|
||||
const newMins = totalMins % 60;
|
||||
const normalizedMins = ((totalMins % 1440) + 1440) % 1440;
|
||||
const newHours = Math.floor(normalizedMins / 60);
|
||||
const newMins = normalizedMins % 60;
|
||||
return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@@ -266,6 +300,101 @@ class EtaController {
|
||||
const [h2, m2] = time2.split(':').map(Number);
|
||||
return (h2 * 60 + m2) - (h1 * 60 + m1);
|
||||
}
|
||||
|
||||
getScheduleTrips(schedule) {
|
||||
const trips = this.parseTrips(schedule.trips);
|
||||
if (trips.length > 0) return trips;
|
||||
|
||||
return (schedule.departure_times || []).map(departureTime => ({
|
||||
departureTime,
|
||||
startStopId: null,
|
||||
endStopId: null,
|
||||
viaStopIds: [],
|
||||
daysOfWeek: [],
|
||||
exceptDaysOfWeek: [],
|
||||
note: ''
|
||||
}));
|
||||
}
|
||||
|
||||
parseTrips(trips) {
|
||||
if (Array.isArray(trips)) return trips;
|
||||
if (typeof trips !== 'string') return [];
|
||||
|
||||
try {
|
||||
const parsedTrips = JSON.parse(trips);
|
||||
return Array.isArray(parsedTrips) ? parsedTrips : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
buildStopsById(routeStops) {
|
||||
return routeStops.reduce((stopsById, stop) => {
|
||||
stopsById[Number(stop.stop_id)] = {
|
||||
...stop,
|
||||
stop_id: Number(stop.stop_id),
|
||||
sequence: Number(stop.sequence),
|
||||
time_offset_minutes: Number(stop.time_offset_minutes || 0)
|
||||
};
|
||||
return stopsById;
|
||||
}, {});
|
||||
}
|
||||
|
||||
tripServesStop(trip, targetStop, stopsById) {
|
||||
const targetSequence = Number(targetStop.sequence);
|
||||
const startStop = trip.startStopId ? stopsById[Number(trip.startStopId)] : null;
|
||||
const endStop = trip.endStopId ? stopsById[Number(trip.endStopId)] : null;
|
||||
|
||||
if (startStop && endStop && startStop.sequence > endStop.sequence) {
|
||||
return targetSequence <= startStop.sequence && targetSequence >= endStop.sequence;
|
||||
}
|
||||
|
||||
if (startStop && targetSequence < startStop.sequence) return false;
|
||||
if (endStop && targetSequence > endStop.sequence) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getTripArrivalTime(trip, targetStop, stopsById) {
|
||||
if (!trip.departureTime) return null;
|
||||
|
||||
const startStop = trip.startStopId ? stopsById[Number(trip.startStopId)] : null;
|
||||
const targetOffset = Number(targetStop.time_offset_minutes || 0);
|
||||
const startOffset = startStop ? Number(startStop.time_offset_minutes || 0) : 0;
|
||||
const offsetDelta = startStop && Number(startStop.sequence) > Number(targetStop.sequence)
|
||||
? startOffset - targetOffset
|
||||
: targetOffset - startOffset;
|
||||
|
||||
return this.addMinutesToTime(trip.departureTime, offsetDelta);
|
||||
}
|
||||
|
||||
isTripActiveOnDate(trip, date) {
|
||||
const isoWeekday = this.getIsoWeekday(date);
|
||||
const daysOfWeek = Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [];
|
||||
const exceptDaysOfWeek = Array.isArray(trip.exceptDaysOfWeek)
|
||||
? trip.exceptDaysOfWeek.map(Number)
|
||||
: [];
|
||||
|
||||
if (daysOfWeek.length > 0 && !daysOfWeek.includes(isoWeekday)) return false;
|
||||
if (exceptDaysOfWeek.includes(isoWeekday)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getIsoWeekday(date) {
|
||||
const day = date.getDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
publicTripInfo(trip) {
|
||||
return {
|
||||
departureTime: trip.departureTime,
|
||||
startStopId: trip.startStopId || null,
|
||||
endStopId: trip.endStopId || null,
|
||||
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds : [],
|
||||
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek : [],
|
||||
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek : [],
|
||||
note: trip.note || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EtaController();
|
||||
module.exports = new EtaController();
|
||||
|
||||
@@ -2,6 +2,8 @@ const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday'];
|
||||
|
||||
class SchedulesController {
|
||||
// Get all schedules for a route
|
||||
async getRouteSchedules(req, res) {
|
||||
@@ -25,7 +27,7 @@ class SchedulesController {
|
||||
END
|
||||
`, [routeId]);
|
||||
|
||||
res.json(result.rows);
|
||||
res.json(result.rows.map(schedule => this.withScheduleTrips(schedule)));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching route schedules:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch schedules' });
|
||||
@@ -51,7 +53,7 @@ class SchedulesController {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
res.json(this.withScheduleTrips(result.rows[0]));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch schedule' });
|
||||
@@ -60,30 +62,28 @@ class SchedulesController {
|
||||
|
||||
// Create schedule
|
||||
async createSchedule(req, res) {
|
||||
const { routeId, dayType, departureTimes, validFrom, validUntil } = req.body;
|
||||
const { routeId, dayType, departureTimes, trips, validFrom, validUntil } = req.body;
|
||||
|
||||
if (!routeId || !dayType || !departureTimes) {
|
||||
if (!routeId || !dayType || (!departureTimes && !trips)) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['routeId', 'dayType', 'departureTimes']
|
||||
required: ['routeId', 'dayType', 'departureTimes or trips']
|
||||
});
|
||||
}
|
||||
|
||||
// Validate day type
|
||||
const validDayTypes = ['weekday', 'saturday', 'sunday', 'holiday'];
|
||||
if (!validDayTypes.includes(dayType)) {
|
||||
if (!VALID_DAY_TYPES.includes(dayType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid day type',
|
||||
valid: validDayTypes
|
||||
valid: VALID_DAY_TYPES
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and sort departure times
|
||||
const sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||
if (!sortedTimes) {
|
||||
const normalizedSchedule = this.normalizeScheduleInput({ departureTimes, trips });
|
||||
if (normalizedSchedule.error) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid departure times format',
|
||||
expected: 'Array of times in HH:MM or HH:MM:SS format'
|
||||
error: normalizedSchedule.error,
|
||||
expected: normalizedSchedule.expected
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,16 +104,23 @@ class SchedulesController {
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO schedules (route_id, day_type, departure_times, valid_from, valid_until)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
|
||||
RETURNING *
|
||||
`, [routeId, dayType, sortedTimes, validFrom || null, validUntil || null]);
|
||||
`, [
|
||||
routeId,
|
||||
dayType,
|
||||
normalizedSchedule.departureTimes,
|
||||
JSON.stringify(normalizedSchedule.trips),
|
||||
validFrom || null,
|
||||
validUntil || null
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
|
||||
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
res.status(201).json(this.withScheduleTrips(result.rows[0]));
|
||||
} catch (error) {
|
||||
logger.error('Error creating schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to create schedule' });
|
||||
@@ -123,7 +130,7 @@ class SchedulesController {
|
||||
// Update schedule
|
||||
async updateSchedule(req, res) {
|
||||
const { id } = req.params;
|
||||
const { departureTimes, validFrom, validUntil } = req.body;
|
||||
const { departureTimes, trips, validFrom, validUntil } = req.body;
|
||||
|
||||
try {
|
||||
// Get current schedule to find route_id
|
||||
@@ -132,12 +139,13 @@ class SchedulesController {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
let sortedTimes = null;
|
||||
if (departureTimes) {
|
||||
sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||
if (!sortedTimes) {
|
||||
let normalizedSchedule = null;
|
||||
if (departureTimes !== undefined || trips !== undefined) {
|
||||
normalizedSchedule = this.normalizeScheduleInput({ departureTimes, trips });
|
||||
if (normalizedSchedule.error) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid departure times format'
|
||||
error: normalizedSchedule.error,
|
||||
expected: normalizedSchedule.expected
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -146,18 +154,25 @@ class SchedulesController {
|
||||
UPDATE schedules
|
||||
SET
|
||||
departure_times = COALESCE($1, departure_times),
|
||||
valid_from = COALESCE($2, valid_from),
|
||||
valid_until = COALESCE($3, valid_until),
|
||||
trips = COALESCE($2::jsonb, trips),
|
||||
valid_from = COALESCE($3, valid_from),
|
||||
valid_until = COALESCE($4, valid_until),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
WHERE id = $5
|
||||
RETURNING *
|
||||
`, [sortedTimes, validFrom, validUntil, id]);
|
||||
`, [
|
||||
normalizedSchedule?.departureTimes || null,
|
||||
normalizedSchedule ? JSON.stringify(normalizedSchedule.trips) : null,
|
||||
validFrom,
|
||||
validUntil,
|
||||
id
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${current.rows[0].route_id}`);
|
||||
|
||||
logger.info(`Schedule ${id} updated`);
|
||||
res.json(result.rows[0]);
|
||||
res.json(this.withScheduleTrips(result.rows[0]));
|
||||
} catch (error) {
|
||||
logger.error('Error updating schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to update schedule' });
|
||||
@@ -251,7 +266,7 @@ class SchedulesController {
|
||||
scheduleType: scheduleType,
|
||||
isHoliday: holidayCheck.rows.length > 0,
|
||||
holidayName: holidayCheck.rows[0]?.name || null,
|
||||
schedule: result.rows[0]
|
||||
schedule: this.withScheduleTrips(result.rows[0])
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching today schedule:', error);
|
||||
@@ -274,7 +289,7 @@ class SchedulesController {
|
||||
try {
|
||||
// Get source schedule
|
||||
const source = await pool.query(`
|
||||
SELECT departure_times FROM schedules
|
||||
SELECT departure_times, trips FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
@@ -286,6 +301,8 @@ class SchedulesController {
|
||||
});
|
||||
}
|
||||
|
||||
const sourceSchedule = this.withScheduleTrips(source.rows[0]);
|
||||
|
||||
// Check if target already exists
|
||||
const existing = await pool.query(`
|
||||
SELECT id FROM schedules
|
||||
@@ -296,21 +313,31 @@ class SchedulesController {
|
||||
// Update existing
|
||||
const result = await pool.query(`
|
||||
UPDATE schedules
|
||||
SET departure_times = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE route_id = $2 AND day_type = $3
|
||||
SET departure_times = $1, trips = $2::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE route_id = $3 AND day_type = $4
|
||||
RETURNING *
|
||||
`, [source.rows[0].departure_times, routeId, toDayType]);
|
||||
`, [
|
||||
sourceSchedule.departure_times,
|
||||
JSON.stringify(sourceSchedule.trips),
|
||||
routeId,
|
||||
toDayType
|
||||
]);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
res.json(this.withScheduleTrips(result.rows[0]));
|
||||
} else {
|
||||
// Create new
|
||||
const result = await pool.query(`
|
||||
INSERT INTO schedules (route_id, day_type, departure_times)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO schedules (route_id, day_type, departure_times, trips)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
RETURNING *
|
||||
`, [routeId, toDayType, source.rows[0].departure_times]);
|
||||
`, [
|
||||
routeId,
|
||||
toDayType,
|
||||
sourceSchedule.departure_times,
|
||||
JSON.stringify(sourceSchedule.trips)
|
||||
]);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
res.status(201).json(this.withScheduleTrips(result.rows[0]));
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
@@ -327,15 +354,11 @@ class SchedulesController {
|
||||
validateAndSortTimes(times) {
|
||||
if (!Array.isArray(times)) return null;
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
|
||||
const validTimes = [];
|
||||
|
||||
for (const time of times) {
|
||||
if (!timeRegex.test(time)) return null;
|
||||
|
||||
// Normalize to HH:MM:SS format
|
||||
const parts = time.split(':');
|
||||
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
|
||||
const normalized = this.normalizeTime(time);
|
||||
if (!normalized) return null;
|
||||
validTimes.push(normalized);
|
||||
}
|
||||
|
||||
@@ -343,6 +366,191 @@ class SchedulesController {
|
||||
validTimes.sort();
|
||||
return validTimes;
|
||||
}
|
||||
|
||||
normalizeScheduleInput({ departureTimes, trips }) {
|
||||
if (trips !== undefined) {
|
||||
return this.normalizeTrips(trips);
|
||||
}
|
||||
|
||||
const sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||
if (!sortedTimes || sortedTimes.length === 0) {
|
||||
return {
|
||||
error: 'Invalid departure times format',
|
||||
expected: 'Non-empty array of times in HH:MM or HH:MM:SS format'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
departureTimes: sortedTimes,
|
||||
trips: sortedTimes.map(departureTime => this.createTrip({ departureTime }))
|
||||
};
|
||||
}
|
||||
|
||||
normalizeTrips(trips) {
|
||||
if (!Array.isArray(trips) || trips.length === 0) {
|
||||
return {
|
||||
error: 'Invalid trips format',
|
||||
expected: 'Non-empty array of trip objects'
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedTrips = [];
|
||||
|
||||
for (const trip of trips) {
|
||||
if (!trip || typeof trip !== 'object') {
|
||||
return {
|
||||
error: 'Invalid trip item',
|
||||
expected: 'Each trip must be an object'
|
||||
};
|
||||
}
|
||||
|
||||
const departureTime = this.normalizeTime(
|
||||
trip.departureTime || trip.departure_time || trip.time
|
||||
);
|
||||
|
||||
if (!departureTime) {
|
||||
return {
|
||||
error: 'Invalid trip departure time',
|
||||
expected: 'Trip departureTime must be HH:MM or HH:MM:SS'
|
||||
};
|
||||
}
|
||||
|
||||
const daysOfWeek = this.normalizeWeekdays(trip.daysOfWeek || trip.days_of_week || []);
|
||||
const exceptDaysOfWeek = this.normalizeWeekdays(
|
||||
trip.exceptDaysOfWeek || trip.except_days_of_week || []
|
||||
);
|
||||
|
||||
if (!daysOfWeek || !exceptDaysOfWeek) {
|
||||
return {
|
||||
error: 'Invalid trip day constraints',
|
||||
expected: 'daysOfWeek and exceptDaysOfWeek must contain ISO weekdays 1-7'
|
||||
};
|
||||
}
|
||||
|
||||
const overlappingDay = daysOfWeek.find(day => exceptDaysOfWeek.includes(day));
|
||||
if (overlappingDay) {
|
||||
return {
|
||||
error: 'Invalid trip day constraints',
|
||||
expected: 'A weekday cannot be both included and excluded'
|
||||
};
|
||||
}
|
||||
|
||||
const viaStopIds = this.normalizeStopIdArray(trip.viaStopIds || trip.via_stop_ids || []);
|
||||
if (!viaStopIds) {
|
||||
return {
|
||||
error: 'Invalid via stop ids',
|
||||
expected: 'viaStopIds must be an array of positive integers'
|
||||
};
|
||||
}
|
||||
|
||||
normalizedTrips.push(this.createTrip({
|
||||
departureTime,
|
||||
startStopId: this.normalizeOptionalId(trip.startStopId || trip.start_stop_id),
|
||||
endStopId: this.normalizeOptionalId(trip.endStopId || trip.end_stop_id),
|
||||
viaStopIds,
|
||||
daysOfWeek,
|
||||
exceptDaysOfWeek,
|
||||
note: typeof trip.note === 'string' ? trip.note.trim().slice(0, 200) : ''
|
||||
}));
|
||||
}
|
||||
|
||||
normalizedTrips.sort((a, b) => a.departureTime.localeCompare(b.departureTime));
|
||||
|
||||
return {
|
||||
departureTimes: normalizedTrips.map(trip => trip.departureTime),
|
||||
trips: normalizedTrips
|
||||
};
|
||||
}
|
||||
|
||||
createTrip({
|
||||
departureTime,
|
||||
startStopId = null,
|
||||
endStopId = null,
|
||||
viaStopIds = [],
|
||||
daysOfWeek = [],
|
||||
exceptDaysOfWeek = [],
|
||||
note = ''
|
||||
}) {
|
||||
return {
|
||||
departureTime,
|
||||
startStopId,
|
||||
endStopId,
|
||||
viaStopIds,
|
||||
daysOfWeek,
|
||||
exceptDaysOfWeek,
|
||||
note
|
||||
};
|
||||
}
|
||||
|
||||
withScheduleTrips(schedule) {
|
||||
const trips = this.parseTrips(schedule.trips);
|
||||
|
||||
if (trips.length > 0) {
|
||||
return { ...schedule, trips };
|
||||
}
|
||||
|
||||
const fallbackTrips = (schedule.departure_times || []).map(departureTime => (
|
||||
this.createTrip({ departureTime: this.normalizeTime(departureTime) })
|
||||
));
|
||||
|
||||
return { ...schedule, trips: fallbackTrips };
|
||||
}
|
||||
|
||||
parseTrips(trips) {
|
||||
if (Array.isArray(trips)) return trips;
|
||||
if (typeof trips !== 'string') return [];
|
||||
|
||||
try {
|
||||
const parsedTrips = JSON.parse(trips);
|
||||
return Array.isArray(parsedTrips) ? parsedTrips : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
normalizeTime(time) {
|
||||
if (typeof time !== 'string') return null;
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
|
||||
if (!timeRegex.test(time)) return null;
|
||||
|
||||
const parts = time.split(':');
|
||||
return `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
|
||||
}
|
||||
|
||||
normalizeOptionalId(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
normalizeStopIdArray(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
|
||||
const ids = [];
|
||||
for (const item of value) {
|
||||
const id = this.normalizeOptionalId(item);
|
||||
if (!id) return null;
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
normalizeWeekdays(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
|
||||
const weekdays = [];
|
||||
for (const item of value) {
|
||||
const day = Number(item);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 7) return null;
|
||||
if (!weekdays.includes(day)) weekdays.push(day);
|
||||
}
|
||||
|
||||
return weekdays.sort();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SchedulesController();
|
||||
module.exports = new SchedulesController();
|
||||
|
||||
@@ -86,7 +86,7 @@ class SyncController {
|
||||
const syncData = {
|
||||
syncType: 'full',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
version: '1.1',
|
||||
data: {
|
||||
routes: routesResult.rows,
|
||||
stops: stopsResult.rows,
|
||||
@@ -247,4 +247,4 @@ class SyncController {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SyncController();
|
||||
module.exports = new SyncController();
|
||||
|
||||
Reference in New Issue
Block a user