diff --git a/backend/migrations/006_schedule_trip_variants.sql b/backend/migrations/006_schedule_trip_variants.sql new file mode 100644 index 0000000..2fbd91f --- /dev/null +++ b/backend/migrations/006_schedule_trip_variants.sql @@ -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; diff --git a/backend/src/controllers/etaController.js b/backend/src/controllers/etaController.js index 2a2cd9d..edb933c 100644 --- a/backend/src/controllers/etaController.js +++ b/backend/src/controllers/etaController.js @@ -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(); \ No newline at end of file +module.exports = new EtaController(); diff --git a/backend/src/controllers/schedulesController.js b/backend/src/controllers/schedulesController.js index 43d8c30..5c973b1 100644 --- a/backend/src/controllers/schedulesController.js +++ b/backend/src/controllers/schedulesController.js @@ -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(); \ No newline at end of file +module.exports = new SchedulesController(); diff --git a/backend/src/controllers/syncController.js b/backend/src/controllers/syncController.js index d1f93f6..812b631 100644 --- a/backend/src/controllers/syncController.js +++ b/backend/src/controllers/syncController.js @@ -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(); \ No newline at end of file +module.exports = new SyncController(); diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 5be90ed..e2e08e0 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -160,9 +160,10 @@ ### Schedule - Таблица: `schedules` -- Поля: `id`, `route_id`, `day_type`, `departure_times`, `valid_from`, `valid_until`, `created_at`, `updated_at` +- Поля: `id`, `route_id`, `day_type`, `departure_times`, `trips`, `valid_from`, `valid_until`, `created_at`, `updated_at` - Enum `day_type`: `weekday`, `saturday`, `sunday`, `holiday` - `departure_times` хранится как массив `TIME[]` +- `trips` хранит расширенное описание каждого рейса: `departureTime`, `startStopId`, `endStopId`, `viaStopIds`, `daysOfWeek`, `exceptDaysOfWeek`, `note`. Это покрывает рейсы “с/до остановки”, “через остановку”, “по пятницам” и “кроме пятницы”. ### Holiday @@ -242,8 +243,9 @@ ### Schedule DTO -- `CreateScheduleRequest`: `{ routeId: number, dayType: ScheduleDayType, departureTimes: string[], validFrom?: string|null, validUntil?: string|null }` -- `UpdateScheduleRequest`: `{ departureTimes?: string[], validFrom?: string|null, validUntil?: string|null }` +- `ScheduleTrip`: `{ departureTime: string, startStopId?: number|null, endStopId?: number|null, viaStopIds?: number[], daysOfWeek?: number[], exceptDaysOfWeek?: number[], note?: string }`, где дни недели — ISO `1..7`, `1 = Monday`, `7 = Sunday` +- `CreateScheduleRequest`: `{ routeId: number, dayType: ScheduleDayType, departureTimes?: string[], trips?: ScheduleTrip[], validFrom?: string|null, validUntil?: string|null }` +- `UpdateScheduleRequest`: `{ departureTimes?: string[], trips?: ScheduleTrip[], validFrom?: string|null, validUntil?: string|null }` - `CopyScheduleRequest`: `{ fromDayType: ScheduleDayType, toDayType: ScheduleDayType }` ### Holiday DTO @@ -715,7 +717,7 @@ ### [POST] /api/v1/routes/:routeId/schedules/copy -- Описание: копирует departure times из одного day type в другой +- Описание: копирует `departure_times` и расширенные `trips` из одного day type в другой - Авторизация: Bearer JWT или `X-API-Key` - Параметры path: `routeId` - Query-параметры: нет @@ -756,9 +758,9 @@ - Request Body: `CreateScheduleRequest` - Response 200/201: созданный `Schedule` - Ошибки: -- `400` — нет `routeId`, `dayType` или `departureTimes` +- `400` — нет `routeId`, `dayType` или одного из полей `departureTimes`/`trips` - `400` — invalid `dayType` -- `400` — invalid departure time format, ожидается `HH:MM` или `HH:MM:SS` +- `400` — invalid departure time/trip format, ожидается `HH:MM` или `HH:MM:SS`, для `trips` также валидируются ISO weekdays `1..7` - `409` — уже есть активное расписание на этот `routeId + dayType` - `401`/`403`/`429` - `500` — `Failed to create schedule` @@ -767,7 +769,7 @@ ### [PUT] /api/v1/schedules/:id -- Описание: обновляет departure times и/или validity window +- Описание: обновляет departure times, trips и/или validity window - Авторизация: Bearer JWT или `X-API-Key` - Параметры path: `id` - Query-параметры: нет @@ -776,7 +778,7 @@ - Response 200/201: обновленный `Schedule` - Ошибки: - `404` — расписание не найдено -- `400` — invalid departure time format +- `400` — invalid departure time/trip format - `401`/`403`/`429` - `500` — `Failed to update schedule` - Связанные сущности: `Schedule` diff --git a/docs/openapi-draft.yaml b/docs/openapi-draft.yaml index 898de83..cb861f5 100644 --- a/docs/openapi-draft.yaml +++ b/docs/openapi-draft.yaml @@ -1960,6 +1960,43 @@ components: ScheduleDayType: type: string enum: [weekday, saturday, sunday, holiday] + ScheduleTrip: + type: object + required: [departureTime] + properties: + departureTime: + type: string + description: Departure time in HH:MM or HH:MM:SS format. + startStopId: + type: integer + nullable: true + description: Optional stop id where a partial trip starts. + endStopId: + type: integer + nullable: true + description: Optional stop id where a partial trip ends. + viaStopIds: + type: array + items: + type: integer + description: Optional via-stop markers for display and route variants. + daysOfWeek: + type: array + items: + type: integer + minimum: 1 + maximum: 7 + description: ISO weekdays when this trip runs, 1=Monday, 7=Sunday. + exceptDaysOfWeek: + type: array + items: + type: integer + minimum: 1 + maximum: 7 + description: ISO weekdays when this trip does not run. + note: + type: string + description: Human-readable note such as "по пятницам" or "через Мариненко". AlertType: type: string enum: [delay, cancellation, detour, info] @@ -2132,6 +2169,10 @@ components: type: array items: type: string + trips: + type: array + items: + $ref: '#/components/schemas/ScheduleTrip' valid_from: type: string format: date @@ -2720,7 +2761,7 @@ components: type: integer CreateScheduleRequest: type: object - required: [routeId, dayType, departureTimes] + required: [routeId, dayType] properties: routeId: type: integer @@ -2730,6 +2771,11 @@ components: type: array items: type: string + description: Legacy flat departure list. If trips is supplied, departureTimes is derived from trips. + trips: + type: array + items: + $ref: '#/components/schemas/ScheduleTrip' validFrom: type: string format: date @@ -2745,6 +2791,11 @@ components: type: array items: type: string + description: Legacy flat departure list. If trips is supplied, departureTimes is derived from trips. + trips: + type: array + items: + $ref: '#/components/schemas/ScheduleTrip' validFrom: type: string format: date diff --git a/frontend/src/pages/SchedulesPage.jsx b/frontend/src/pages/SchedulesPage.jsx index aadb290..d483e22 100644 --- a/frontend/src/pages/SchedulesPage.jsx +++ b/frontend/src/pages/SchedulesPage.jsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Calendar, Clock, Plus, Edit, Trash2, Copy, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; -import { routesApi, schedulesApi, holidaysApi } from '../services/api'; +import { Calendar, Plus, Edit, Trash2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; +import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services/api'; import { PageLoader } from '../components/AnimatedComponents'; const DAY_TYPES = [ @@ -11,9 +10,20 @@ const DAY_TYPES = [ { value: 'holiday', label: 'Праздники', description: 'По расписанию воскресенья' } ]; +const WEEKDAYS = [ + { value: 1, label: 'Пн' }, + { value: 2, label: 'Вт' }, + { value: 3, label: 'Ср' }, + { value: 4, label: 'Чт' }, + { value: 5, label: 'Пт' }, + { value: 6, label: 'Сб' }, + { value: 7, label: 'Вс' } +]; + export default function SchedulesPage() { const [routes, setRoutes] = useState([]); const [selectedRoute, setSelectedRoute] = useState(null); + const [routeStops, setRouteStops] = useState([]); const [schedules, setSchedules] = useState([]); const [loading, setLoading] = useState(true); const [showEditor, setShowEditor] = useState(false); @@ -29,6 +39,7 @@ export default function SchedulesPage() { useEffect(() => { if (selectedRoute) { loadSchedules(selectedRoute.id); + loadRouteStops(selectedRoute.id); } }, [selectedRoute]); @@ -56,6 +67,16 @@ export default function SchedulesPage() { } }; + const loadRouteStops = async (routeId) => { + try { + const response = await routeStopsApi.getRouteStops(routeId); + setRouteStops(response.data); + } catch (error) { + console.error('Error loading route stops:', error); + setRouteStops([]); + } + }; + const loadUpcomingHolidays = async () => { try { const response = await holidaysApi.getUpcoming(5); @@ -69,7 +90,8 @@ export default function SchedulesPage() { setEditingSchedule({ routeId: selectedRoute.id, dayType: dayType, - departureTimes: [] + departureTimes: [], + trips: [] }); setShowEditor(true); }; @@ -241,7 +263,7 @@ export default function SchedulesPage() { {schedule ? ( - {schedule.departure_times?.length || 0} рейсов + {getScheduleTrips(schedule).length} рейсов ) : ( @@ -266,15 +288,25 @@ export default function SchedulesPage() { {isExpanded && (
-
- {schedule.departure_times?.map((time, idx) => ( - + {getScheduleTrips(schedule).map((trip, idx) => ( +
- {time.substring(0, 5)} - + + {formatScheduleTime(trip.departureTime)} + + {getTripLabels(trip, routeStops).map((label) => ( + + {label} + + ))} +
))}
@@ -345,6 +377,7 @@ export default function SchedulesPage() { { setShowEditor(false); @@ -356,10 +389,8 @@ export default function SchedulesPage() { ); } -function ScheduleEditor({ schedule, routeName, onSave, onCancel }) { - const [times, setTimes] = useState( - schedule.departure_times?.map(t => t.substring(0, 5)) || [] - ); +function ScheduleEditor({ schedule, routeName, routeStops, onSave, onCancel }) { + const [trips, setTrips] = useState(() => normalizeTripsForEditor(schedule)); const [newTime, setNewTime] = useState(''); const [bulkInput, setBulkInput] = useState(''); const [showBulkInput, setShowBulkInput] = useState(false); @@ -375,83 +406,92 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) { return; } - if (times.includes(newTime)) { - alert('Это время уже добавлено'); - return; - } - - const newTimes = [...times, newTime].sort(); - setTimes(newTimes); + setTrips(sortTrips([...trips, createEditorTrip(newTime)])); setNewTime(''); }; - const handleRemoveTime = (index) => { - const newTimes = times.filter((_, i) => i !== index); - setTimes(newTimes); + const handleRemoveTrip = (index) => { + setTrips(trips.filter((_, i) => i !== index)); + }; + + const handleUpdateTrip = (index, patch) => { + setTrips(trips.map((trip, i) => ( + i === index ? { ...trip, ...patch } : trip + ))); + }; + + const handleToggleDay = (index, field, day) => { + const trip = trips[index]; + const current = trip[field] || []; + const next = current.includes(day) + ? current.filter(value => value !== day) + : [...current, day].sort(); + + const oppositeField = field === 'daysOfWeek' ? 'exceptDaysOfWeek' : 'daysOfWeek'; + handleUpdateTrip(index, { + [field]: next, + [oppositeField]: (trip[oppositeField] || []).filter(value => value !== day) + }); }; const handleBulkAdd = () => { - // Parse bulk input (comma, space, or newline separated) - const inputTimes = bulkInput - .split(/[,\s\n]+/) + const inputTrips = bulkInput + .split(/\s*\|\s*|[,;\n]+/) .map(t => t.trim()) .filter(t => t); - const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/; - const validTimes = []; - const invalidTimes = []; + const validTrips = []; + const invalidItems = []; - for (const time of inputTimes) { - if (timeRegex.test(time)) { - // Normalize time format - const parts = time.split(':'); - const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}`; - if (!times.includes(normalized) && !validTimes.includes(normalized)) { - validTimes.push(normalized); - } + for (const item of inputTrips) { + const parsedTrip = parseBulkTrip(item, routeStops); + if (parsedTrip) { + validTrips.push(parsedTrip); } else { - invalidTimes.push(time); + invalidItems.push(item); } } - if (invalidTimes.length > 0) { - alert(`Неверный формат: ${invalidTimes.join(', ')}`); + if (invalidItems.length > 0) { + alert(`Неверный формат: ${invalidItems.join(', ')}`); } - if (validTimes.length > 0) { - const newTimes = [...times, ...validTimes].sort(); - setTimes(newTimes); + if (validTrips.length > 0) { + setTrips(sortTrips([...trips, ...validTrips])); setBulkInput(''); setShowBulkInput(false); } }; const handleSave = () => { - if (times.length === 0) { + if (trips.length === 0) { alert('Добавьте хотя бы одно время отправления'); return; } + const apiTrips = sortTrips(trips).map(toApiTrip); + onSave({ routeId: schedule.routeId || schedule.route_id, dayType: schedule.dayType || schedule.day_type, - departureTimes: times.map(t => `${t}:00`) + departureTimes: apiTrips.map(trip => trip.departureTime), + trips: apiTrips }); }; // Generate quick add times const generateQuickTimes = (startHour, endHour, interval) => { - const generatedTimes = []; + const existingTimes = new Set(trips.map(trip => trip.departureTime)); + const generatedTrips = []; for (let h = startHour; h <= endHour; h++) { for (let m = 0; m < 60; m += interval) { const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; - if (!times.includes(time)) { - generatedTimes.push(time); + if (!existingTimes.has(time)) { + generatedTrips.push(createEditorTrip(time)); } } } - const newTimes = [...times, ...generatedTimes].sort(); - setTimes(newTimes); + setTrips(sortTrips([...trips, ...generatedTrips])); }; return ( @@ -490,7 +530,7 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) { 07-20 × 60мин