const db = require("../db"); const { DEV_MOCK } = require("../lib/env"); const { parseWeekdaysValue, toDateKey, expandRecurringScheduleRows, scheduleMatchesFilters, parseScheduleFilters, scheduleOccursOnDate, materializeScheduleOverviewRow, mapRecurringOccurrenceRows, materializeScheduleRowsInRange } = require("../lib/schedule"); const { reportError } = require("../lib/report-error"); const { ensureScheduleStopProgressTable } = require("../lib/schema"); // Schedules repository methods (PostgreSQL mode). Every method returns // { statusCode, body } mirroring the original handler's DB branch. async function updateDriverScheduleStatus(req) { try { const { scheduleId } = req.params; const { status } = req.body; const validStatuses = [ "planned", "in_progress", "completed", "cancelled", "active", ]; if (!validStatuses.includes(status)) { return { statusCode: 400, body: { error: "invalid_status" } }; } const normalizedStatus = status === "active" ? "in_progress" : status; const existing = await db.query( `SELECT * FROM schedules WHERE id = $1`, [scheduleId], ); if (existing.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } const scheduleRow = existing.rows[0]; const weekdays = parseWeekdaysValue(scheduleRow.weekdays); const requestedDate = req.body.date || null; if (weekdays && weekdays.length > 0) { let targetDateKey = requestedDate; if (!targetDateKey) { const todayKey = new Date().toISOString().slice(0, 10); if (scheduleOccursOnDate(scheduleRow, todayKey)) { targetDateKey = todayKey; } } if (targetDateKey) { const templateStart = new Date( scheduleRow.start_time ?? scheduleRow.startTime, ); const templateEnd = new Date( scheduleRow.end_time ?? scheduleRow.endTime, ); const occurrenceStart = new Date(`${targetDateKey}T00:00:00`); occurrenceStart.setHours( templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds(), ); let occurrenceEnd = null; if ( Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime()) ) { const duration = templateEnd.getTime() - templateStart.getTime(); occurrenceEnd = new Date(occurrenceStart.getTime() + duration); } const existingInstance = await db.query( `SELECT * FROM schedules WHERE template_id = $1 AND DATE(start_time) = $2`, [scheduleId, targetDateKey], ); if (existingInstance.rows.length > 0) { const upd = await db.query( ` UPDATE schedules SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING * `, [normalizedStatus, existingInstance.rows[0].id], ); return { statusCode: 200, body: { schedule: upd.rows[0] } }; } const hasTemplateIdCol = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='template_id' `); let insertResult; if (hasTemplateIdCol.rows.length > 0) { insertResult = await db.query( ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM schedules) INSERT INTO schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status, template_id) SELECT next_id.id, $1, $2, $3, $4, $5, $6, $7 FROM next_id RETURNING * `, [ scheduleRow.bus_line_id, scheduleRow.bus_id, scheduleRow.driver_id, occurrenceStart.toISOString(), occurrenceEnd ? occurrenceEnd.toISOString() : null, normalizedStatus, scheduleId, ], ); } else { insertResult = await db.query( ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM schedules) INSERT INTO schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status) SELECT next_id.id, $1, $2, $3, $4, $5, $6 FROM next_id RETURNING * `, [ scheduleRow.bus_line_id, scheduleRow.bus_id, scheduleRow.driver_id, occurrenceStart.toISOString(), occurrenceEnd ? occurrenceEnd.toISOString() : null, normalizedStatus, ], ); } return { statusCode: 200, body: { schedule: insertResult.rows[0] } }; } } const result = await db.query( ` UPDATE schedules SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING * `, [normalizedStatus, scheduleId], ); if (result.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } return { statusCode: 200, body: { schedule: result.rows[0] } }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function listSchedules(req) { try { const filters = parseScheduleFilters(req.query, { statuses: ["planned", "active"], }); let query = ` SELECT s.id, s.bus_line_id, s.bus_id, s.driver_id, s.start_time, s.end_time, s.weekdays, s.status, bl.line_number, b.name as bus_name, bl.estimated_duration_minutes FROM schedules s LEFT JOIN bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id WHERE 1 = 1 `; const params = []; if (filters.statuses.length > 0) { query += ` AND s.status = ANY($${params.length + 1}::text[])`; params.push(filters.statuses); } if (filters.busLineId !== null) { query += ` AND s.bus_line_id = $${params.length + 1}`; params.push(filters.busLineId); } if (filters.busId !== null) { query += ` AND s.bus_id = $${params.length + 1}`; params.push(filters.busId); } if (filters.driverId !== null) { query += ` AND s.driver_id = $${params.length + 1}`; params.push(filters.driverId); } query += ` ORDER BY s.start_time ASC`; const result = await db.query(query, params); const lineIds = Array.from( new Set( result.rows .map((row) => Number(row.bus_line_id)) .filter(Number.isFinite), ), ); const routeStopsByLine = new Map(); if (filters.stopId !== null && lineIds.length > 0) { const routeStopsResult = await db.query( ` SELECT rs.bus_line_id, rs.stop_id, rs.stop_order FROM route_stops rs WHERE rs.bus_line_id = ANY($1::int[]) ORDER BY rs.bus_line_id ASC, rs.stop_order ASC `, [lineIds], ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); if (!routeStopsByLine.has(busLineId)) { routeStopsByLine.set(busLineId, []); } routeStopsByLine.get(busLineId).push(row); } } const schedules = expandRecurringScheduleRows(result.rows) .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)) .map((row) => ({ id: Number(row.id), busLineId: Number(row.bus_line_id), busId: Number(row.bus_id), driverId: null, startTime: row.start_time, endTime: row.end_time, weekdays: row.weekdays ? typeof row.weekdays === "string" ? JSON.parse(row.weekdays) : row.weekdays : [], status: row.status, lineNumber: row.line_number, busName: row.bus_name, estimatedDurationMinutes: Number(row.estimated_duration_minutes) || null, isRecurringInstance: row.is_recurring_instance || false, templateId: row.template_id || null, })); return { statusCode: 200, body: { schedules } }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function getSchedulesOverview(req) { try { const filters = parseScheduleFilters(req.query, { statuses: ["planned", "active"], }); const overviewDateKey = filters.date || null; const hasWeekdaysColumn = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema = 'transit.bus' AND table_name = 'schedules' AND column_name = 'weekdays' `); const baseSelect = ` SELECT s.id, s.bus_line_id AS "busLineId", s.bus_id AS "busId", s.driver_id AS "driverId", s.start_time AS "startTime", s.end_time AS "endTime", s.status, s.created_at AS "createdAt", bl.line_number AS "lineNumber", bl.estimated_duration_minutes AS "estimatedDurationMinutes", b.name AS "busName", b.license_plate AS "licensePlate", TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName"`; const result = hasWeekdaysColumn.rows.length > 0 ? await db.query( `${baseSelect}, s.weekdays AS weekdays FROM schedules s LEFT JOIN bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id LEFT JOIN workers.employees e ON s.driver_id = e.id WHERE s.status = ANY($1::text[]) ORDER BY s.start_time ASC`, [filters.statuses], ) : await db.query( `${baseSelect}, NULL AS weekdays FROM schedules s LEFT JOIN bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id LEFT JOIN workers.employees e ON s.driver_id = e.id WHERE s.status = ANY($1::text[]) ORDER BY s.start_time ASC`, [filters.statuses], ); const lineIds = Array.from( new Set( result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite), ), ); const routeStopsByLine = new Map(); if (lineIds.length > 0) { const routeStopsResult = await db.query( ` SELECT rs.bus_line_id, rs.stop_id, rs.stop_order, rs.estimated_arrival_minutes, s.name AS stop_name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = ANY($1::int[]) ORDER BY rs.bus_line_id ASC, rs.stop_order ASC `, [lineIds], ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); if (!routeStopsByLine.has(busLineId)) { routeStopsByLine.set(busLineId, []); } routeStopsByLine.get(busLineId).push(row); } } let schedules = []; if (filters.fromDate && filters.toDate) { schedules = materializeScheduleRowsInRange( result.rows, filters.fromDate, filters.toDate, routeStopsByLine, ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)); } else { schedules = mapRecurringOccurrenceRows( result.rows, overviewDateKey, routeStopsByLine, ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)); } if (!DEV_MOCK && schedules.length > 0) { const scheduleIds = [ ...new Set(schedules.map((s) => Number(s.id)).filter(Number.isFinite)), ]; const occurrenceDates = [ ...new Set( schedules.map((s) => toDateKey(s.startTime)).filter(Boolean), ), ]; if (scheduleIds.length > 0 && occurrenceDates.length > 0) { const progressRes = await db.query( ` SELECT schedule_id, scheduled_passed_date, stop_order, actual_passed_at, scheduled_passed_at, delay_minutes FROM schedule_stop_progress WHERE schedule_id = ANY($1::int[]) AND scheduled_passed_date = ANY($2::date[]) ORDER BY schedule_id ASC, scheduled_passed_date ASC, actual_passed_at ASC `, [scheduleIds, occurrenceDates], ); const progressByKey = new Map(); for (const row of progressRes.rows) { const key = `${row.schedule_id}|${row.scheduled_passed_date}`; if (!progressByKey.has(key)) progressByKey.set(key, []); progressByKey.get(key).push(row); } schedules = schedules.map((s) => { const key = `${s.id}|${toDateKey(s.startTime)}`; const progRows = progressByKey.get(key) || []; const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || []) .length; const passedStops = progRows.length; let currentDelayMinutes = null; if (progRows.length > 0) { const latest = progRows[progRows.length - 1]; currentDelayMinutes = Number(latest.delay_minutes ?? latest.delay_minutes) || null; } const statusOverride = passedStops >= totalStops && totalStops > 0 ? "completed" : passedStops > 0 ? "in_progress" : s.status; return { ...s, status: statusOverride, passedStops, totalStops, currentDelayMinutes, }; }); } } return { statusCode: 200, body: { date: overviewDateKey, summary: { total: schedules.length, recurring: schedules.filter( (schedule) => schedule.scheduleType === "recurring", ).length, oneTime: schedules.filter( (schedule) => schedule.scheduleType === "one_time", ).length, }, schedules, }, }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function listRecurringSchedules(req) { try { const filters = parseScheduleFilters(req.query); const hasWeekdaysColumn = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema = 'transit.bus' AND table_name = 'schedules' AND column_name = 'weekdays' `); if (hasWeekdaysColumn.rows.length === 0) { return { statusCode: 200, body: { schedules: [] } }; } const result = await db.query(` SELECT s.id, s.bus_line_id AS "busLineId", s.bus_id AS "busId", s.driver_id AS "driverId", s.start_time AS "startTime", s.end_time AS "endTime", s.weekdays, bl.line_number AS "lineNumber", b.name AS "busName", b.license_plate AS "licensePlate", TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName", s.created_at AS "createdAt" FROM schedules s LEFT JOIN bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id LEFT JOIN workers.employees e ON s.driver_id = e.id WHERE s.weekdays IS NOT NULL `); const lineIds = Array.from( new Set( result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite), ), ); const routeStopsByLine = new Map(); if (lineIds.length > 0) { const routeStopsResult = await db.query( ` SELECT rs.bus_line_id, rs.stop_id, rs.stop_order, rs.estimated_arrival_minutes, s.name AS stop_name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = ANY($1::int[]) ORDER BY rs.bus_line_id ASC, rs.stop_order ASC `, [lineIds], ); for (const row of routeStopsResult.rows) { const busLineId = Number(row.bus_line_id); if (!routeStopsByLine.has(busLineId)) { routeStopsByLine.set(busLineId, []); } routeStopsByLine.get(busLineId).push(row); } } const schedules = result.rows .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine)) .map((row) => { const schedule = materializeScheduleOverviewRow( row, null, routeStopsByLine, ); delete schedule.status; return schedule; }); return { statusCode: 200, body: { schedules } }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function getScheduleById(req) { try { const { scheduleId } = req.params; const result = await db.query( ` SELECT s.id, s.bus_line_id, s.bus_id, s.start_time, s.end_time, s.status, s.weekdays, bl.line_number, bl.start_stop, bl.end_stop, b.name as bus_name, b.license_plate FROM schedules s LEFT JOIN bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id WHERE s.id = $1 `, [scheduleId], ); if (result.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } const row = result.rows[0]; const weekdays = parseWeekdaysValue(row.weekdays); return { statusCode: 200, body: { schedule: { id: Number(row.id), busLineId: Number(row.bus_line_id), busId: Number(row.bus_id), startTime: row.start_time, endTime: row.end_time, status: row.status, weekdays: weekdays, lineNumber: row.line_number, startStop: row.start_stop, endStop: row.end_stop, busName: row.bus_name, licensePlate: row.license_plate, }, }, }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function getScheduleStops(req) { try { const { scheduleId } = req.params; const scheduleResult = await db.query( `SELECT bus_line_id FROM schedules WHERE id = $1`, [scheduleId], ); if (scheduleResult.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } const busLineId = scheduleResult.rows[0].bus_line_id; const stopsResult = await db.query( ` SELECT rs.id, rs.stop_order, s.name, s.latitude, s.longitude, rs.estimated_arrival_minutes FROM route_stops rs JOIN stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC `, [busLineId], ); return { statusCode: 200, body: { stops: stopsResult.rows.map((row) => ({ id: row.id, order: row.stop_order, name: row.name, latitude: parseFloat(row.latitude), longitude: parseFloat(row.longitude), estimatedArrivalMinutes: row.estimated_arrival_minutes, })), }, }; } catch (err) { console.error(err); reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function markStopPassed(req) { try { const scheduleId = Number(req.params.scheduleId); const stopOrder = Number(req.params.stopOrder); if ( !Number.isFinite(scheduleId) || !Number.isFinite(stopOrder) || stopOrder < 1 ) { return { statusCode: 400, body: { error: "invalid_schedule_or_stop_order" }, }; } const actualPassedAt = req.body?.actualPassedAt ? new Date(req.body.actualPassedAt) : new Date(); if (Number.isNaN(actualPassedAt.getTime())) { return { statusCode: 400, body: { error: "invalid_actual_passed_at" } }; } await ensureScheduleStopProgressTable(); const scheduleResult = await db.query( ` SELECT s.id, s.bus_line_id, s.start_time, s.status FROM schedules s WHERE s.id = $1 `, [scheduleId], ); if (scheduleResult.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } const schedule = scheduleResult.rows[0]; const routeStopResult = await db.query( ` SELECT rs.stop_id, rs.stop_order, rs.estimated_arrival_minutes, s.name AS stop_name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 AND rs.stop_order = $2 LIMIT 1 `, [schedule.bus_line_id, stopOrder], ); if (routeStopResult.rows.length === 0) { return { statusCode: 404, body: { error: "stop_not_found_for_schedule" } }; } const routeStop = routeStopResult.rows[0]; let occurrenceDateKey = req.query.date || req.body.date || toDateKey(schedule.start_time); const weekdays = parseWeekdaysValue(schedule.weekdays); let instanceScheduleId = scheduleId; if (weekdays && weekdays.length > 0) { if (!occurrenceDateKey) { const todayKey = new Date().toISOString().slice(0, 10); if (scheduleOccursOnDate(schedule, todayKey)) occurrenceDateKey = todayKey; } if (occurrenceDateKey) { const existingInstance = await db.query( `SELECT id FROM schedules WHERE template_id = $1 AND DATE(start_time) = $2 LIMIT 1`, [scheduleId, occurrenceDateKey], ); if (existingInstance.rows.length > 0) { instanceScheduleId = existingInstance.rows[0].id; } else { const templateStart = new Date(schedule.start_time); const templateEnd = schedule.end_time ? new Date(schedule.end_time) : null; const occurrenceStart = new Date(`${occurrenceDateKey}T00:00:00`); occurrenceStart.setHours( templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds(), ); let occurrenceEnd = null; if ( templateEnd && Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime()) ) { const duration = templateEnd.getTime() - templateStart.getTime(); occurrenceEnd = new Date(occurrenceStart.getTime() + duration); } const hasTemplateIdCol = await db.query(` SELECT 1 FROM information_schema.columns WHERE table_schema='transit' AND table_name='schedules' AND column_name='template_id' `); let insertRes; if (hasTemplateIdCol.rows.length > 0) { insertRes = await db.query( ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM schedules) INSERT INTO schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status, template_id) SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 FROM next_id RETURNING id `, [ schedule.bus_line_id, schedule.bus_id, schedule.driver_id, occurrenceStart.toISOString(), occurrenceEnd ? occurrenceEnd.toISOString() : null, scheduleId, ], ); } else { insertRes = await db.query( ` WITH next_id AS (SELECT COALESCE(MAX(id), 0) + 1 AS id FROM schedules) INSERT INTO schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status) SELECT next_id.id, $1, $2, $3, $4, $5, 'planned' FROM next_id RETURNING id `, [ schedule.bus_line_id, schedule.bus_id, schedule.driver_id, occurrenceStart.toISOString(), occurrenceEnd ? occurrenceEnd.toISOString() : null, ], ); } instanceScheduleId = insertRes.rows[0].id; } } } const templateStart = new Date(schedule.start_time); const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`); scheduledPassedAt.setHours( templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds(), ); scheduledPassedAt.setMinutes( scheduledPassedAt.getMinutes() + (Number(routeStop.estimated_arrival_minutes) || 0), ); await db.query( ` INSERT INTO schedule_stop_progress ( schedule_id, bus_line_id, stop_id, stop_order, stop_name, estimated_arrival_minutes, scheduled_passed_at, scheduled_passed_date, actual_passed_at, marked_by_user_id ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (schedule_id, stop_order, scheduled_passed_date) DO UPDATE SET actual_passed_at = EXCLUDED.actual_passed_at, marked_by_user_id = EXCLUDED.marked_by_user_id, created_at = NOW() `, [ instanceScheduleId, Number(schedule.bus_line_id), Number(routeStop.stop_id), stopOrder, routeStop.stop_name, Number(routeStop.estimated_arrival_minutes) || 0, scheduledPassedAt.toISOString(), occurrenceDateKey, actualPassedAt.toISOString(), Number(req.user?.userId) || null, ], ); const totalsResult = await db.query( ` SELECT (SELECT COUNT(*)::int FROM route_stops WHERE bus_line_id = $1) AS total_stops, (SELECT COUNT(*)::int FROM schedule_stop_progress WHERE schedule_id = $2 AND scheduled_passed_date = $3) AS passed_stops `, [schedule.bus_line_id, instanceScheduleId, occurrenceDateKey], ); const totalStops = Number(totalsResult.rows[0]?.total_stops || 0); const passedStops = Number(totalsResult.rows[0]?.passed_stops || 0); const isCompleted = totalStops > 0 && passedStops >= totalStops; const nextStatus = isCompleted ? "completed" : "in_progress"; await db.query( ` UPDATE schedules SET status = $2, end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END WHERE id = $1 `, [instanceScheduleId, nextStatus, actualPassedAt.toISOString()], ); const delayMinutes = Math.round( (actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000, ); return { statusCode: 200, body: { scheduleId, stopOrder, status: nextStatus, delayMinutes, passedStops, totalStops, }, }; } catch (err) { console.error( "POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:", err, ); reportError({ partofservice: "POST /driver/schedules/:scheduleId/stops/:stopOrder/passed", error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } async function getPublicScheduleProgress(req) { try { const scheduleId = Number(req.params.scheduleId); if (!Number.isFinite(scheduleId)) { return { statusCode: 400, body: { error: "invalid_schedule_id" } }; } await ensureScheduleStopProgressTable(); const scheduleResult = await db.query( ` SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, bl.line_number, bl.start_stop, bl.end_stop FROM schedules s JOIN bus_lines bl ON bl.id = s.bus_line_id WHERE s.id = $1 LIMIT 1 `, [scheduleId], ); if (scheduleResult.rows.length === 0) { return { statusCode: 404, body: { error: "schedule_not_found" } }; } const schedule = scheduleResult.rows[0]; const routeStopsResult = await db.query( ` SELECT rs.stop_id, rs.stop_order, rs.estimated_arrival_minutes, s.name AS stop_name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id WHERE rs.bus_line_id = $1 ORDER BY rs.stop_order ASC `, [schedule.bus_line_id], ); const progressDate = req.query.date || req.query.occurrenceDate || null; let progressResult; if (progressDate) { progressResult = await db.query( ` SELECT stop_order, actual_passed_at, scheduled_passed_at, scheduled_passed_date FROM schedule_stop_progress WHERE schedule_id = $1 AND scheduled_passed_date = $2 ORDER BY stop_order ASC `, [scheduleId, progressDate], ); } else { progressResult = await db.query( ` SELECT stop_order, actual_passed_at, scheduled_passed_at, scheduled_passed_date FROM schedule_stop_progress WHERE schedule_id = $1 ORDER BY scheduled_passed_at ASC, stop_order ASC `, [scheduleId], ); } const progressByOrder = new Map( progressResult.rows.map((row) => [Number(row.stop_order), row]), ); const latestProgress = progressResult.rows.length > 0 ? progressResult.rows[progressResult.rows.length - 1] : null; let currentDelayMinutes = null; if ( latestProgress && latestProgress.actual_passed_at && latestProgress.scheduled_passed_at ) { const actualTime = new Date(latestProgress.actual_passed_at); const scheduledTime = new Date(latestProgress.scheduled_passed_at); currentDelayMinutes = Math.round( (actualTime.getTime() - scheduledTime.getTime()) / 60000, ); } const occurrenceDateKeyForMapping = progressDate || toDateKey(schedule.start_time); const stops = routeStopsResult.rows.map((row) => { const etaMinutes = Number(row.estimated_arrival_minutes) || 0; const templateStart = new Date(schedule.start_time); const scheduledPassedAt = new Date( `${occurrenceDateKeyForMapping}T00:00:00`, ); scheduledPassedAt.setHours( templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds(), ); scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + etaMinutes); const progress = progressByOrder.get(Number(row.stop_order)); return { stopId: Number(row.stop_id), stopOrder: Number(row.stop_order), stopName: row.stop_name, estimatedArrivalMinutes: etaMinutes, scheduledPassedAt: scheduledPassedAt.toISOString(), actualPassedAt: progress?.actual_passed_at || null, delayMinutes: (() => { if (progress?.actual_passed_at && progress?.scheduled_passed_at) { const actualTime = new Date(progress.actual_passed_at); const scheduledTime = new Date(progress.scheduled_passed_at); return Math.round( (actualTime.getTime() - scheduledTime.getTime()) / 60000, ); } return null; })(), status: progress ? "passed" : "pending", }; }); const passedStops = stops.filter((s) => s.status === "passed"); const nextStop = stops.find((s) => s.status === "pending") || null; const driverState = passedStops.length === 0 ? "not_started" : passedStops.length >= stops.length ? "completed" : "en_route"; return { statusCode: 200, body: { schedule: { id: Number(schedule.id), lineNumber: Number(schedule.line_number), startStop: schedule.start_stop, endStop: schedule.end_stop, startTime: schedule.start_time, endTime: schedule.end_time, status: schedule.status, }, driverState, currentDelayMinutes, passedStopCount: passedStops.length, totalStopCount: stops.length, nextStop: nextStop ? { ...nextStop, predictedPassedAt: new Date( new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000, ).toISOString(), } : null, stops, updatedAt: latestProgress?.actual_passed_at || null, }, }; } catch (err) { console.error("GET /public/schedules/:scheduleId/progress error:", err); reportError({ partofservice: "GET /public/schedules/:scheduleId/progress", error: err.stack || err.message }); return { statusCode: 500, body: { error: "internal_error", details: err.message } }; } } module.exports = { updateDriverScheduleStatus, listSchedules, getSchedulesOverview, listRecurringSchedules, getScheduleById, getScheduleStops, markStopPassed, getPublicScheduleProgress, };