diff --git a/admin_ui/src/components/TimetableViewer.jsx b/admin_ui/src/components/TimetableViewer.jsx
index 4174609..7196c47 100644
--- a/admin_ui/src/components/TimetableViewer.jsx
+++ b/admin_ui/src/components/TimetableViewer.jsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([])
+ const [summary, setSummary] = useState(null)
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([])
@@ -28,13 +29,14 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
setLoading(true)
setError(null)
try {
- let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
+ let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
if (selectedLineId) {
url += `&lineId=${selectedLineId}`
}
const res = await fetch(url)
const data = await res.json()
setSchedules(data.schedules || [])
+ setSummary(data.summary || null)
} catch (err) {
setError('Failed to load schedules: ' + err.message)
} finally {
@@ -72,6 +74,20 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
+ {summary && (
+
+
+ Total: {summary.total}
+
+
+ Vast: {summary.recurring}
+
+
+ Eenmalig: {summary.oneTime}
+
+
+ )}
+
{loading && Loading schedules...
}
{error && {error}
}
@@ -81,7 +97,8 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
Time |
Line |
Vehicle |
- Status |
+ Day status |
+ Delay |
@@ -104,14 +121,19 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
style={{
padding: '4px 8px',
borderRadius: '4px',
- backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
- color: schedule.status === 'active' ? '#166534' : '#374151',
+ backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
+ color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
fontSize: '0.9rem',
}}
>
{schedule.status}
+
+ {typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
+ ? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
+ : '—'}
+ |
))
})}
diff --git a/bus_terminal/lib/screens/recurring_schedules_screen.dart b/bus_terminal/lib/screens/recurring_schedules_screen.dart
index c66f936..4e45fca 100644
--- a/bus_terminal/lib/screens/recurring_schedules_screen.dart
+++ b/bus_terminal/lib/screens/recurring_schedules_screen.dart
@@ -24,10 +24,7 @@ class _RecurringSchedulesScreenState extends State {
}
void _loadRecurringSchedules() {
- // Load schedules from a reference date, then filter for recurring ones
- _schedulesFuture = _scheduleService.getSchedules(DateTime.now()).then((schedules) {
- return schedules.where((s) => s.isRecurring).toList();
- });
+ _schedulesFuture = _scheduleService.getRecurringSchedules();
}
String _formatWeekdays(List weekdays) {
diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart
index 2d67c38..1115a62 100644
--- a/bus_terminal/lib/services/schedule_service.dart
+++ b/bus_terminal/lib/services/schedule_service.dart
@@ -38,6 +38,31 @@ class ScheduleService {
}
}
+ /// Get all recurring schedule templates, independent of a specific date.
+ Future> getRecurringSchedules({int? lineId}) async {
+ try {
+ final queryParameters = {};
+ if (lineId != null) {
+ queryParameters['lineId'] = lineId.toString();
+ }
+
+ final uri = Uri.parse('$_apiBaseUrl/schedules/recurring').replace(queryParameters: queryParameters);
+ final response = await http.get(uri);
+
+ if (response.statusCode == 200) {
+ final data = jsonDecode(response.body) as Map;
+ return (data['schedules'] as List? ?? [])
+ .map((schedule) => schedule as Map)
+ .map(ServiceSchedule.fromJson)
+ .toList();
+ }
+
+ throw Exception('Failed to load recurring schedules: ${response.statusCode}');
+ } catch (e) {
+ throw Exception('Recurring schedule error: $e');
+ }
+ }
+
/// Get stop list for a specific schedule.
Future> getScheduleStops(int scheduleId) async {
try {
diff --git a/transit_api/index.js b/transit_api/index.js
index d79e7d6..f9f377a 100644
--- a/transit_api/index.js
+++ b/transit_api/index.js
@@ -2736,6 +2736,138 @@ app.get('/schedules/overview', async (req, res) => {
}
});
+// ============================================
+// PUBLIC - Get all recurring schedule templates (no date filtering)
+// ============================================
+app.get('/schedules/recurring', async (req, res) => {
+ try {
+ const filters = parseScheduleFilters(req.query);
+
+ 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 || [])
+ .filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0)
+ .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 || [],
+ lineNumber: busLine?.lineNumber || 0,
+ busName: bus?.name || 'Unknown Bus',
+ licensePlate: bus?.licensePlate || '',
+ createdAt: schedule.createdAt,
+ };
+ });
+
+ const schedules = baseRows
+ .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine))
+ .map(row => {
+ const materialized = materializeScheduleOverviewRow(row, null, routeStopsByLine);
+ return {
+ ...materialized,
+ status: undefined,
+ };
+ });
+
+ return res.json({ schedules });
+ }
+
+ const hasWeekdaysColumn = await db.query(`
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'transit' AND table_name = 'schedules' AND column_name = 'weekdays'
+ `);
+
+ if (hasWeekdaysColumn.rows.length === 0) {
+ return res.json({ 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 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.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 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 = result.rows
+ .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine))
+ .map(row => {
+ const schedule = materializeScheduleOverviewRow(row, null, routeStopsByLine);
+ delete schedule.status;
+ return schedule;
+ });
+
+ res.json({ schedules });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'internal_error', details: err.message });
+ }
+});
+
app.get('/schedules/:scheduleId', async (req, res) => {
try {
const { scheduleId } = req.params;