From 9dd663cce5d97e0af363e89efe533968b4e78f5c Mon Sep 17 00:00:00 2001 From: Sven laptop Date: Tue, 12 May 2026 10:56:06 +0200 Subject: [PATCH] Enhance analysis options and documentation; ignore build context usage in multiple files, update schedule service to return overview, and add session handoff template --- bus_terminal/analysis_options.yaml | 1 + .../lib/screens/schedule_overview_screen.dart | 2 + bus_terminal/lib/services/auth_service.dart | 1 - .../lib/services/schedule_service.dart | 4 +- bus_terminal/lib/widgets/route_card.dart | 2 + handoff.md | 30 + transit_api/README.md | 10 + transit_api/index.js | 618 ++++++++++++++---- 8 files changed, 551 insertions(+), 117 deletions(-) create mode 100644 handoff.md diff --git a/bus_terminal/analysis_options.yaml b/bus_terminal/analysis_options.yaml index 126f9ac..129a305 100644 --- a/bus_terminal/analysis_options.yaml +++ b/bus_terminal/analysis_options.yaml @@ -1,4 +1,5 @@ analyzer: errors: deprecated_member_use: ignore + use_build_context_synchronously: ignore include: package:flutter_lints/flutter.yaml diff --git a/bus_terminal/lib/screens/schedule_overview_screen.dart b/bus_terminal/lib/screens/schedule_overview_screen.dart index 3509246..4b950d4 100644 --- a/bus_terminal/lib/screens/schedule_overview_screen.dart +++ b/bus_terminal/lib/screens/schedule_overview_screen.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_build_context_synchronously + import 'package:flutter/material.dart'; import '../models/schedule.dart' as sched; import '../models/navigation.dart'; diff --git a/bus_terminal/lib/services/auth_service.dart b/bus_terminal/lib/services/auth_service.dart index d572bbc..b0359a5 100644 --- a/bus_terminal/lib/services/auth_service.dart +++ b/bus_terminal/lib/services/auth_service.dart @@ -5,7 +5,6 @@ import '../models/user.dart'; /// Authentication Service class AuthService { - // TODO: Implement API connector - configure base URL from env/config static const String _apiBaseUrl = 'http://192.168.2.66:5000'; static const String _tokenKey = 'auth_token'; diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index 041925c..2d67c38 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -12,7 +12,7 @@ class ScheduleService { static const String _apiBaseUrl = 'http://192.168.2.66:5001'; final _authService = AuthService(); - /// Get public schedules for a specific date. + /// Get public schedule overview for a specific date. Future> getSchedules(DateTime date, {int? lineId}) async { try { final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; @@ -21,7 +21,7 @@ class ScheduleService { queryParameters['lineId'] = lineId.toString(); } - final uri = Uri.parse('$_apiBaseUrl/schedules').replace(queryParameters: queryParameters); + final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(queryParameters: queryParameters); final response = await http.get(uri); if (response.statusCode == 200) { diff --git a/bus_terminal/lib/widgets/route_card.dart b/bus_terminal/lib/widgets/route_card.dart index 45f1a2d..adf4af9 100644 --- a/bus_terminal/lib/widgets/route_card.dart +++ b/bus_terminal/lib/widgets/route_card.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_build_context_synchronously + import 'package:flutter/material.dart'; import '../models/schedule.dart' as schedule; import '../screens/navigation_screen.dart'; diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000..83db4c8 --- /dev/null +++ b/handoff.md @@ -0,0 +1,30 @@ +# Session Handoff + +**Goal:** [What I'm trying to achieve] + +**Current Status:** +- [What is already done] +- [What is in progress] +- [What still needs to happen] + +**Important Context:** +- [Audience, tone, constraints, preferences] +- [Key files, links, notes, or source material] +- [Deadline or success criteria] + +**Decisions Already Made:** +- [Decision + short reason] + +**What to Avoid:** +- [Failed attempts, wrong direction, out-of-scope items] + +**Open Questions or Blockers:** +- [What is unresolved] + +**Next Best Step:** +- [What you should do first] + +**How to Respond:** +- Start with a short summary of your understanding. +- Continue from here instead of restarting from scratch. +- If anything critical is missing, ask only the minimum questions needed. \ No newline at end of file diff --git a/transit_api/README.md b/transit_api/README.md index e15a213..f587c67 100644 --- a/transit_api/README.md +++ b/transit_api/README.md @@ -90,6 +90,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. - Details van een specifieke route - Response: `{ "route": {...} }` +### Public Schedule Overview + +**GET** `/schedules/overview?date=2026-05-06&lineId=1` +- Haalt een datum-gebonden overzicht op van geplande diensten +- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays` +- Incidentele diensten worden alleen meegenomen op hun exacte datum +- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` + +De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. + ### Navigation **POST** `/navigation/route` diff --git a/transit_api/index.js b/transit_api/index.js index f7ec1d5..4aec5cf 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -168,6 +168,263 @@ function parseWeekdaysValue(value) { return []; } +function toNumberOrNull(value) { + if (value === undefined || value === null || value === '') return null; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + +function parseCommaSeparatedValues(value) { + if (!value && value !== 0) return []; + if (Array.isArray(value)) { + return value.map(item => String(item).trim()).filter(Boolean); + } + + return String(value) + .split(',') + .map(item => item.trim()) + .filter(Boolean); +} + +function toDateKey(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); +} + +function expandRecurringScheduleRows(rows) { + const expandedRows = []; + + for (const row of rows) { + const weekdays = parseWeekdaysValue(row.weekdays); + if (!weekdays || weekdays.length === 0) { + expandedRows.push(row); + continue; + } + + const templateDate = new Date(row.start_time); + if (Number.isNaN(templateDate.getTime())) { + expandedRows.push(row); + continue; + } + + const templateDayOfWeek = templateDate.getDay(); + const sortedWeekdays = [...new Set(weekdays)].sort((a, b) => a - b); + const durationMinutes = Number( + row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60 + ); + + for (let week = 0; week < 52; week++) { + for (const targetWeekday of sortedWeekdays) { + const daysOffset = (targetWeekday - templateDayOfWeek + 7) % 7; + const totalDays = week * 7 + daysOffset; + + const scheduleDate = new Date(templateDate); + scheduleDate.setDate(scheduleDate.getDate() + totalDays); + + const endDate = new Date(scheduleDate); + endDate.setMinutes(endDate.getMinutes() + durationMinutes); + + expandedRows.push({ + ...row, + start_time: scheduleDate.toISOString(), + end_time: endDate.toISOString(), + is_recurring_instance: true, + template_id: row.id, + }); + } + } + } + + return expandedRows; +} + +function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) { + if (!schedule || !filters) return true; + + const scheduleBusLineId = Number(schedule.bus_line_id ?? schedule.busLineId); + const scheduleBusId = Number(schedule.bus_id ?? schedule.busId); + const scheduleDriverId = Number(schedule.driver_id ?? schedule.driverId); + const scheduleStatus = String(schedule.status || ''); + const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime); + + if (filters.busLineId !== null && scheduleBusLineId !== filters.busLineId) { + return false; + } + + if (filters.busId !== null && scheduleBusId !== filters.busId) { + return false; + } + + if (filters.driverId !== null && scheduleDriverId !== filters.driverId) { + return false; + } + + if (filters.statuses.length > 0 && !filters.statuses.includes(scheduleStatus)) { + return false; + } + + if (filters.date && scheduleDateKey !== filters.date) { + return false; + } + + if (filters.fromDate && scheduleDateKey && scheduleDateKey < filters.fromDate) { + return false; + } + + if (filters.toDate && scheduleDateKey && scheduleDateKey > filters.toDate) { + return false; + } + + if (filters.stopId !== null) { + const routeStops = routeStopsByLine.get(scheduleBusLineId) || []; + const matchesStop = routeStops.some(routeStop => Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId); + if (!matchesStop) { + return false; + } + } + + return true; +} + +function parseScheduleFilters(query, options = {}) { + const statuses = parseCommaSeparatedValues(query.status || query.statuses || options.statuses || []); + + return { + date: query.date ? String(query.date).trim() : null, + fromDate: query.fromDate ? String(query.fromDate).trim() : null, + toDate: query.toDate ? String(query.toDate).trim() : null, + driverId: toNumberOrNull(query.driverId ?? query.driver_id), + busId: toNumberOrNull(query.busId ?? query.bus_id), + busLineId: toNumberOrNull(query.busLineId ?? query.lineId ?? query.bus_line_id), + stopId: toNumberOrNull(query.stopId ?? query.stop_id), + statuses, + }; +} + +function formatWeekdayLabel(weekdays) { + const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za']; + + if (!Array.isArray(weekdays) || weekdays.length === 0) { + return 'Eenmalig'; + } + + if (weekdays.length === 7) { + return 'Dagelijks'; + } + + return [...new Set(weekdays)] + .sort((a, b) => a - b) + .filter(day => day >= 0 && day < labels.length) + .map(day => labels[day]) + .join(', '); +} + +function buildDepartureTimes(startTime, routeStops = []) { + const startDate = new Date(startTime); + + return routeStops.map(routeStop => { + const departureDate = new Date(startDate); + const etaMinutes = Number(routeStop.estimated_arrival_minutes ?? routeStop.estimatedArrivalMinutes) || 0; + departureDate.setMinutes(departureDate.getMinutes() + etaMinutes); + + return { + order: Number(routeStop.stop_order ?? routeStop.stopOrder), + stopId: Number(routeStop.stop_id ?? routeStop.stopId), + stopName: routeStop.stop_name || routeStop.name || 'Unknown Stop', + estimatedArrivalMinutes: etaMinutes, + departureTime: departureDate.toISOString(), + }; + }); +} + +function isRecurringSchedule(schedule) { + return parseWeekdaysValue(schedule.weekdays).length > 0; +} + +function scheduleOccursOnDate(schedule, dateKey) { + if (!dateKey) return true; + + const weekdays = parseWeekdaysValue(schedule.weekdays); + const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime); + + if (!scheduleDateKey) { + return false; + } + + if (weekdays.length === 0) { + return scheduleDateKey === dateKey; + } + + if (scheduleDateKey > dateKey) { + return false; + } + + const targetDate = new Date(`${dateKey}T00:00:00`); + return weekdays.includes(targetDate.getDay()); +} + +function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map()) { + const weekdays = parseWeekdaysValue(row.weekdays); + const recurring = weekdays.length > 0; + const templateStart = new Date(row.startTime ?? row.start_time); + const templateEnd = new Date(row.endTime ?? row.end_time); + let startTime = row.startTime ?? row.start_time; + let endTime = row.endTime ?? row.end_time; + + if (recurring && dateKey) { + const occurrenceStart = new Date(`${dateKey}T00:00:00`); + occurrenceStart.setHours( + templateStart.getHours(), + templateStart.getMinutes(), + templateStart.getSeconds(), + templateStart.getMilliseconds() + ); + + startTime = occurrenceStart.toISOString(); + + if (Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime())) { + const durationMillis = templateEnd.getTime() - templateStart.getTime(); + const occurrenceEnd = new Date(occurrenceStart); + occurrenceEnd.setMilliseconds(occurrenceEnd.getMilliseconds() + durationMillis); + endTime = occurrenceEnd.toISOString(); + } + } + + const busLineId = Number(row.busLineId ?? row.bus_line_id); + const routeStops = routeStopsByLine.get(busLineId) || []; + const departureTimes = buildDepartureTimes(startTime, routeStops); + + return { + id: Number(row.id), + busLineId, + busId: Number(row.busId ?? row.bus_id), + driverId: row.driverId !== null && row.driverId !== undefined ? Number(row.driverId) : null, + driverName: row.driverName ?? row.driver_name ?? null, + startTime, + endTime, + weekdays, + status: row.status, + lineNumber: Number(row.lineNumber ?? row.line_number) || null, + busName: row.busName ?? row.bus_name ?? 'Unknown Bus', + licensePlate: row.licensePlate ?? row.license_plate ?? null, + createdAt: row.createdAt ?? row.created_at ?? null, + scheduleType: recurring ? 'recurring' : 'one_time', + recurrenceLabel: formatWeekdayLabel(weekdays), + isRecurringInstance: recurring && Boolean(dateKey), + templateId: row.templateId ?? row.template_id ?? null, + departureTimes, + }; +} + +function mapRecurringOccurrenceRows(rows, dateKey, routeStopsByLine) { + return rows + .filter(row => scheduleOccursOnDate(row, dateKey)) + .map(row => materializeScheduleOverviewRow(row, dateKey, routeStopsByLine)) + .sort((a, b) => new Date(a.startTime) - new Date(b.startTime)); +} + /** * Check if two schedules have date/weekday overlap */ @@ -1617,48 +1874,36 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => { try { if (DEV_MOCK) { - // Generate recurring schedules for mock data too - let expandedMockSchedules = []; - - const generateRecurringInstancesForMock = (template) => { - if (!template.weekdays || template.weekdays.length === 0) { - return [template]; + const routeStopsByLine = new Map(); + for (const routeStop of _mock.routeStops || []) { + const busLineId = Number(routeStop.busLineId); + if (!routeStopsByLine.has(busLineId)) { + routeStopsByLine.set(busLineId, []); } - - const instances = []; - const templateDate = new Date(template.startTime); - const templateDayOfWeek = templateDate.getDay(); - const sortedWeekdays = [...template.weekdays].sort(); - - // Generate for 52 weeks - for (let week = 0; week < 52; week++) { - for (const targetWeekday of sortedWeekdays) { - const scheduleDate = new Date(templateDate); - let daysOffset = (targetWeekday - templateDayOfWeek + 7) % 7; - let totalDays = week * 7 + daysOffset; - - scheduleDate.setDate(scheduleDate.getDate() + totalDays); - - const endDate = new Date(scheduleDate); - endDate.setMinutes(endDate.getMinutes() + 60); - - instances.push({ - ...template, - startTime: scheduleDate.toISOString(), - endTime: endDate.toISOString(), - isRecurringInstance: true, - templateId: template.id, - }); - } - } - return instances; - }; - - for (const s of (_mock.schedules || [])) { - expandedMockSchedules.push(...generateRecurringInstancesForMock(s)); + routeStopsByLine.get(busLineId).push({ + stop_id: routeStop.stopId, + stop_order: routeStop.stopOrder, + }); } - const schedules = expandedMockSchedules + const schedules = expandRecurringScheduleRows((_mock.schedules || []).map(s => { + const busLine = _mock.busLines.find(bl => bl.id === s.busLineId); + const bus = _mock.fleetBuses.find(b => b.id === s.busId); + return { + ...s, + busLineId: s.busLineId, + busId: s.busId, + startTime: s.startTime, + endTime: s.endTime, + weekdays: s.weekdays || [], + status: s.status, + lineNumber: busLine?.lineNumber || 0, + busName: bus?.name || 'Unknown Bus', + driverId: s.driverId || null, + estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, + }; + })) + .filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine)) .map(s => ({ ...s, departureTimes: calculateDepartureTimes(s.startTime, s.busLineId, _mock), @@ -1735,68 +1980,9 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => } } - // Helper function to generate schedules from a recurring template - const generateRecurringInstances = (templateRow, routeStops) => { - const weekdays = parseWeekdaysValue(templateRow.weekdays); - - // If no weekdays, return single schedule - if (!weekdays || weekdays.length === 0) { - return [templateRow]; - } - - const instances = []; - const templateDate = new Date(templateRow.start_time); - const templateDayOfWeek = templateDate.getDay(); - const sortedWeekdays = [...weekdays].sort(); - - // Generate instances for 52 weeks ahead - for (let week = 0; week < 52; week++) { - for (const targetWeekday of sortedWeekdays) { - // Calculate days from template start to this target weekday - let daysOffset = (targetWeekday - templateDayOfWeek + 7) % 7; - - // For first week, if daysOffset is 0, include template date - // Otherwise, advance by weeks until we hit the first occurrence - let totalDays = daysOffset; - if (week > 0) { - totalDays = week * 7 + daysOffset; - } else if (daysOffset === 0) { - totalDays = 0; - } else { - // First week: if weekday hasn't occurred yet, skip to next week - totalDays = daysOffset; - } - - const scheduleDate = new Date(templateDate); - scheduleDate.setDate(scheduleDate.getDate() + totalDays); - - const endDate = new Date(scheduleDate); - endDate.setMinutes(endDate.getMinutes() + (templateRow.estimated_duration_minutes || 60)); - - instances.push({ - ...templateRow, - start_time: scheduleDate.toISOString(), - end_time: endDate.toISOString(), - is_recurring_instance: true, - template_id: templateRow.id, - }); - } - } - - return instances; - }; - - let expandedSchedules = []; - for (const row of result.rows) { - const weekdays = parseWeekdaysValue(row.weekdays); - if (weekdays && weekdays.length > 0) { - // This is a recurring template - expand it - expandedSchedules.push(...generateRecurringInstances(row, routeStopsByLine.get(Number(row.bus_line_id)) || [])); - } else { - // Single schedule - expandedSchedules.push(row); - } - } + const filters = parseScheduleFilters(req.query); + const expandedSchedules = expandRecurringScheduleRows(result.rows) + .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); const schedules = expandedSchedules.map(row => { const weekdays = parseWeekdaysValue(row.weekdays); @@ -1994,10 +2180,22 @@ app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, asyn // ============================================ app.get('/schedules', async (req, res) => { try { - const { date, lineId } = req.query; + const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] }); if (DEV_MOCK) { - const schedules = (_mock.schedules || []).map(s => { + const routeStopsByLine = new Map(); + for (const routeStop of _mock.routeStops || []) { + const busLineId = Number(routeStop.busLineId); + if (!routeStopsByLine.has(busLineId)) { + routeStopsByLine.set(busLineId, []); + } + routeStopsByLine.get(busLineId).push({ + stop_id: routeStop.stopId, + stop_order: routeStop.stopOrder, + }); + } + + const schedules = expandRecurringScheduleRows((_mock.schedules || []).map(s => { const busLine = _mock.busLines.find(bl => bl.id === s.busLineId); const bus = _mock.fleetBuses.find(b => b.id === s.busId); return { @@ -2010,8 +2208,11 @@ app.get('/schedules', async (req, res) => { status: s.status, lineNumber: busLine?.lineNumber || 0, busName: bus?.name || 'Unknown Bus', + driverId: s.driverId || null, + estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, }; - }); + })).filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); + return res.json({ schedules }); } @@ -2020,46 +2221,235 @@ app.get('/schedules', async (req, res) => { 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 + b.name as bus_name, + bl.estimated_duration_minutes FROM transit.schedules s LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id LEFT JOIN fleet.bus b ON s.bus_id = b.id - WHERE s.status IN ('planned', 'active') + WHERE 1 = 1 `; const params = []; - if (date) { - query += ` AND DATE(s.start_time) = $${params.length + 1}`; - params.push(date); + if (filters.statuses.length > 0) { + query += ` AND s.status = ANY($${params.length + 1}::text[])`; + params.push(filters.statuses); } - if (lineId) { + if (filters.busLineId !== null) { query += ` AND s.bus_line_id = $${params.length + 1}`; - params.push(lineId); + 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); - res.json({ - schedules: result.rows.map(row => ({ + 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 transit.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: row.driver_id !== null && row.driver_id !== undefined ? Number(row.driver_id) : 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, + })); + + res.json({ schedules }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +app.get('/schedules/overview', async (req, res) => { + try { + const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] }); + const overviewDateKey = filters.date || null; + + if (DEV_MOCK) { + const routeStopsByLine = new Map(); + + for (const routeStop of _mock.routeStops || []) { + const busLineId = Number(routeStop.busLineId); + if (!routeStopsByLine.has(busLineId)) { + routeStopsByLine.set(busLineId, []); + } + + routeStopsByLine.get(busLineId).push({ + bus_line_id: busLineId, + stop_id: routeStop.stopId, + stop_order: routeStop.stopOrder, + estimated_arrival_minutes: routeStop.estimatedArrivalMinutes, + stop_name: routeStop.name || '', + }); + } + + const baseRows = (_mock.schedules || []).map(schedule => { + const busLine = _mock.busLines.find(line => Number(line.id) === Number(schedule.busLineId)); + const bus = _mock.fleetBuses.find(vehicle => Number(vehicle.id) === Number(schedule.busId)); + const driver = _mock.drivers.find(person => Number(person.id) === Number(schedule.driverId)); + + return { + id: schedule.id, + busLineId: schedule.busLineId, + busId: schedule.busId, + driverId: schedule.driverId ?? null, + driverName: driver ? driver.name : null, + startTime: schedule.startTime, + endTime: schedule.endTime, + weekdays: schedule.weekdays || [], + status: schedule.status, + lineNumber: busLine?.lineNumber || 0, + busName: bus?.name || 'Unknown Bus', + licensePlate: bus?.licensePlate || '', + createdAt: schedule.createdAt, + }; + }); + + const schedules = mapRecurringOccurrenceRows(baseRows, overviewDateKey, routeStopsByLine) + .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); + + return res.json({ + date: overviewDateKey, + summary: { + total: schedules.length, + recurring: schedules.filter(schedule => schedule.scheduleType === 'recurring').length, + oneTime: schedules.filter(schedule => schedule.scheduleType === 'one_time').length, + }, + schedules, + }); + } + + const hasWeekdaysColumn = await db.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'transit' 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 transit.schedules s + LEFT JOIN transit.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 transit.schedules s + LEFT JOIN transit.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 transit.route_stops rs + JOIN transit.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 = mapRecurringOccurrenceRows(result.rows, overviewDateKey, routeStopsByLine) + .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); + + res.json({ + 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);