diff --git a/bus_terminal/lib/main.dart b/bus_terminal/lib/main.dart index ce24709..048e061 100644 --- a/bus_terminal/lib/main.dart +++ b/bus_terminal/lib/main.dart @@ -3,6 +3,7 @@ import 'screens/login_screen.dart'; import 'screens/schedule_overview_screen.dart'; import 'services/auth_service.dart'; import 'theme/app_theme.dart'; +import 'services/app_navigator.dart'; void main() { runApp(const MainApp()); @@ -15,6 +16,8 @@ class MainApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Bus Terminal', + navigatorKey: navigatorKey, + scaffoldMessengerKey: messengerKey, theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), themeMode: ThemeMode.system, diff --git a/bus_terminal/lib/models/user.dart b/bus_terminal/lib/models/user.dart index 3d0481c..1de7a49 100644 --- a/bus_terminal/lib/models/user.dart +++ b/bus_terminal/lib/models/user.dart @@ -19,7 +19,8 @@ class User { } else if (rawUserCode is String) { parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode')); } else { - throw FormatException('Missing userCode'); + // If the backend doesn't return a userCode, treat as unknown (-1) + parsedUserCode = -1; } return User( diff --git a/bus_terminal/lib/screens/schedule_overview_screen.dart b/bus_terminal/lib/screens/schedule_overview_screen.dart index 21d19cd..b3211f8 100644 --- a/bus_terminal/lib/screens/schedule_overview_screen.dart +++ b/bus_terminal/lib/screens/schedule_overview_screen.dart @@ -27,7 +27,7 @@ class _ScheduleOverviewScreenState extends State with Ti void initState() { super.initState(); _scheduleFuture = _scheduleService.getSchedules(_selectedDate); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); } @override @@ -120,12 +120,16 @@ class _ScheduleOverviewScreenState extends State with Ti ], ), const SizedBox(height: 20), - SizedBox( + SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () async { + onPressed: () async { try { - await _scheduleService.updateScheduleStatus(svc.id, 'in_progress'); + await _scheduleService.updateScheduleStatus( + svc.id, + 'in_progress', + date: svc.startTime, + ); if (!mounted) return; Navigator.of(sheetContext).pop(); @@ -157,11 +161,18 @@ class _ScheduleOverviewScreenState extends State with Ti )) .toList(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops), - ), - ); + try { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops), + ), + ); + } catch (navErr) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')), + ); + } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -227,6 +238,7 @@ class _ScheduleOverviewScreenState extends State with Ti tabs: const [ Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'), Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'), + Tab(icon: Icon(Icons.timeline), text: 'Voortgang'), ], ), actions: [ @@ -249,12 +261,131 @@ class _ScheduleOverviewScreenState extends State with Ti scheduleFuture: _scheduleFuture, ), const RecurringSchedulesScreen(), + _ProgressTab( + scheduleService: _scheduleService, + selectedDate: _selectedDate, + scheduleFuture: _scheduleFuture, + ), ], ), ); } } +class _ProgressTab extends StatelessWidget { + final ScheduleService scheduleService; + final DateTime selectedDate; + final Future> scheduleFuture; + + const _ProgressTab({ + required this.scheduleService, + required this.selectedDate, + required this.scheduleFuture, + }); + + @override + Widget build(BuildContext context) { + final dateStr = '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}'; + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Voortgang voor $dateStr', style: Theme.of(context).textTheme.titleLarge), + IconButton( + onPressed: () async { + // refresh handled by parent via reselecting date; show a simple feedback + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ververs de datum in Dagschema-tab om andere datum te laden'))); + }, + icon: const Icon(Icons.info_outline), + ), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: scheduleFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); + if (snapshot.hasError) return Center(child: Text('Fout bij laden: ${snapshot.error}')); + + final schedules = snapshot.data ?? []; + if (schedules.isEmpty) return Center(child: Text('Geen diensten op $dateStr')); + + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: schedules.length, + itemBuilder: (context, index) { + final svc = schedules[index]; + return Card( + child: ListTile( + title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'), + subtitle: Text('${_formatTime(svc.startTime)}'), + trailing: TextButton( + onPressed: () async { + try { + final progress = await scheduleService.getScheduleProgress(svc.id, selectedDate); + if (!context.mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + final stops = (progress['stops'] as List?) ?? []; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Voortgang — Lijn ${svc.lineNumber}', style: Theme.of(ctx).textTheme.headlineSmall), + const SizedBox(height: 8), + Text('Status: ${progress['schedule']?['status'] ?? 'onbekend'}'), + Text('Driver state: ${progress['driverState'] ?? 'onbekend'}'), + Text('Passed: ${progress['passedStopCount'] ?? 0} / ${progress['totalStopCount'] ?? 0}'), + const SizedBox(height: 12), + const Text('Haltes:'), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: stops.length, + itemBuilder: (c, i) { + final s = stops[i] as Map; + return ListTile( + dense: true, + title: Text('${s['stopOrder']}. ${s['stopName']}'), + subtitle: Text('Scheduled: ${s['scheduledPassedAt'] ?? '-'}\nActual: ${s['actualPassedAt'] ?? '-'}'), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e'))); + } + }, + child: const Text('Bekijk voortgang'), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} + class _DailyScheduleTab extends StatelessWidget { final ScheduleService scheduleService; final DateTime selectedDate; diff --git a/bus_terminal/lib/services/app_navigator.dart b/bus_terminal/lib/services/app_navigator.dart new file mode 100644 index 0000000..c28be33 --- /dev/null +++ b/bus_terminal/lib/services/app_navigator.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'auth_service.dart'; + +/// Global navigator and messenger keys so services can navigate/logout +final GlobalKey navigatorKey = GlobalKey(); +final GlobalKey messengerKey = GlobalKey(); + +/// Handle expired authentication: clear stored token and navigate to login. +Future handleAuthExpired({String? message}) async { + try { + await AuthService().logout(); + } catch (_) {} + + // Show a message if possible + try { + final messenger = messengerKey.currentState; + if (messenger != null && message != null) { + messenger.showSnackBar(SnackBar(content: Text(message))); + } + } catch (_) {} + + // Navigate to login, removing all previous routes + try { + final nav = navigatorKey.currentState; + if (nav != null) { + nav.pushNamedAndRemoveUntil('/login', (r) => false); + } + } catch (_) {} +} diff --git a/bus_terminal/lib/services/auth_service.dart b/bus_terminal/lib/services/auth_service.dart index b0359a5..868f7bf 100644 --- a/bus_terminal/lib/services/auth_service.dart +++ b/bus_terminal/lib/services/auth_service.dart @@ -5,7 +5,7 @@ import '../models/user.dart'; /// Authentication Service class AuthService { - static const String _apiBaseUrl = 'http://192.168.2.66:5000'; + static const String _apiBaseUrl = 'http://api.transit.local:5000'; static const String _tokenKey = 'auth_token'; late SharedPreferences _prefs; diff --git a/bus_terminal/lib/services/exceptions.dart b/bus_terminal/lib/services/exceptions.dart index ad072f0..7818b7c 100644 --- a/bus_terminal/lib/services/exceptions.dart +++ b/bus_terminal/lib/services/exceptions.dart @@ -1,9 +1,16 @@ /// Custom exception for authentication errors +import 'app_navigator.dart'; + class UnauthorizedException implements Exception { final String message; - - UnauthorizedException(this.message); - + + UnauthorizedException(this.message) { + // Trigger centralized logout/navigation asynchronously + try { + handleAuthExpired(message: message); + } catch (_) {} + } + @override String toString() => message; } diff --git a/bus_terminal/lib/services/navigation_service.dart b/bus_terminal/lib/services/navigation_service.dart index 0c4e968..d18684b 100644 --- a/bus_terminal/lib/services/navigation_service.dart +++ b/bus_terminal/lib/services/navigation_service.dart @@ -85,7 +85,7 @@ class NavigationService { throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}'); } } else { - throw Exception('Failed to get route: ${response.statusCode}'); + throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}'); } } catch (e) { throw Exception('Navigation error: $e'); @@ -110,7 +110,25 @@ class NavigationService { final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) { - throw Exception('Failed to get route: ${response.statusCode}'); + if (response.statusCode == 400) { + final distanceMeters = _sumDistancesBetween(stops); + final durationSec = (distanceMeters / 11.111111).toInt(); + final leg = RouteLeg.fromPoints( + points: stops, + distance: distanceMeters, + duration: durationSec, + summary: 'Direct fallback', + ); + return NavigationRoute( + start: stops.first, + end: stops.last, + legs: [leg], + totalDistance: distanceMeters, + totalDuration: durationSec, + ); + } + + throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}'); } final data = jsonDecode(response.body) as Map?; @@ -165,7 +183,7 @@ class NavigationService { final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec); return OptimizedRouteResult(route: route, orderedStops: stops); } - throw Exception('Failed to get optimized route: ${response.statusCode}'); + throw Exception('Failed to get optimized route: ${response.statusCode} -- response body: ${response.body}'); } final data = jsonDecode(response.body) as Map?; @@ -287,7 +305,7 @@ class NavigationService { final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct'); return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)]; } else { - throw Exception('Failed to get routes: ${response.statusCode}'); + throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}'); } } catch (e) { throw Exception('Navigation error: $e'); diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index 160496a..60e002f 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -7,9 +7,7 @@ import 'exceptions.dart'; /// Schedule/Route Service class ScheduleService { - // TODO: Implement API connector - configure base URL from env/config - // Transit API runs on port 5001 - static const String _apiBaseUrl = 'http://192.168.2.66:5001'; + static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl'; final _authService = AuthService(); /// Get public schedule overview for a specific date. @@ -141,12 +139,28 @@ class ScheduleService { return getDailySchedule(DateTime.now()); } + /// Get per-date progress for a schedule occurrence (public endpoint) + Future> getScheduleProgress(int scheduleId, DateTime date) async { + try { + final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + final uri = Uri.parse('$_apiBaseUrl/public/schedules/$scheduleId/progress').replace(queryParameters: {'date': dateStr}); + final response = await http.get(uri); + + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + + throw Exception('Failed to load progress: ${response.statusCode}'); + } catch (e) { + throw Exception('Schedule progress error: $e'); + } + } + /// Get route details Future getRouteDetails(int routeId) async { try { final headers = await _authService.getAuthHeaders(); - // Transit API exposes route details at /routes/:routeId final response = await http.get( Uri.parse('$_apiBaseUrl/routes/$routeId'), headers: headers, @@ -156,10 +170,10 @@ class ScheduleService { final data = jsonDecode(response.body) as Map; return Route.fromJson(data); } else if (response.statusCode == 401 || response.statusCode == 403) { - await _authService.logout(); throw UnauthorizedException('Session expired - Please login again'); } else { - throw Exception('Failed to load route: ${response.statusCode}'); + final body = response.body; + throw Exception('Failed to load route: ${response.statusCode} -- response body: $body'); } } catch (e) { if (e is UnauthorizedException) rethrow; @@ -180,7 +194,6 @@ class ScheduleService { ); if (response.statusCode == 401 || response.statusCode == 403) { - await _authService.logout(); throw UnauthorizedException('Session expired - Please login again'); } else if (response.statusCode != 200) { throw Exception('Failed to update status: ${response.statusCode}'); @@ -192,18 +205,23 @@ class ScheduleService { } /// Update schedule status for driver-facing schedule workflows. - Future updateScheduleStatus(int scheduleId, String status) async { + Future updateScheduleStatus(int scheduleId, String status, {DateTime? date}) async { try { final headers = await _authService.getAuthHeaders(); + final body = {'status': status}; + if (date != null) { + final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + body['date'] = dateStr; + } + final response = await http.patch( Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'), headers: headers, - body: jsonEncode({'status': status}), + body: jsonEncode(body), ); if (response.statusCode == 401 || response.statusCode == 403) { - await _authService.logout(); throw UnauthorizedException('Session expired - Please login again'); } else if (response.statusCode != 200) { throw Exception('Failed to update schedule status: ${response.statusCode}'); @@ -226,27 +244,25 @@ class ScheduleService { if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; - final stops = (data['stops'] as List?) - ?.map((s) => s as Map) - .map((s) { - final lat = (s['latitude'] ?? s['lat']) as num?; - final lon = (s['longitude'] ?? s['lon']) as num?; - final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?; - if (lat == null || lon == null) return null; - return NavigationPoint( - latitude: lat.toDouble(), - longitude: lon.toDouble(), - name: name, - ); - }) - .whereType() - .toList() ?? []; + final raw = data['stops'] as List? ?? []; + final stops = raw.map((s) => s as Map).map((s) { + final lat = (s['latitude'] ?? s['lat']) as num?; + final lon = (s['longitude'] ?? s['lon']) as num?; + final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?; + if (lat == null || lon == null) return null; + return NavigationPoint( + latitude: lat.toDouble(), + longitude: lon.toDouble(), + name: name, + ); + }).whereType().toList(); + return stops; } else if (response.statusCode == 401 || response.statusCode == 403) { - await _authService.logout(); throw UnauthorizedException('Session expired - Please login again'); } else { - throw Exception('Failed to load stops: ${response.statusCode}'); + final body = response.body; + throw Exception('Failed to load stops: ${response.statusCode} -- response body: $body'); } } catch (e) { if (e is UnauthorizedException) rethrow; @@ -263,12 +279,13 @@ class ScheduleService { try { final headers = await _authService.getAuthHeaders(); + final body = {}; + if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String(); + final response = await http.post( Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'), headers: headers, - body: jsonEncode({ - if (actualPassedAt != null) 'actualPassedAt': actualPassedAt.toIso8601String(), - }), + body: jsonEncode(body), ); if (response.statusCode == 401 || response.statusCode == 403) { diff --git a/bus_terminal/lib/widgets/route_card.dart b/bus_terminal/lib/widgets/route_card.dart index adf4af9..012759f 100644 --- a/bus_terminal/lib/widgets/route_card.dart +++ b/bus_terminal/lib/widgets/route_card.dart @@ -67,11 +67,18 @@ class RouteCard extends StatelessWidget { // Ignore status update errors here; navigation should still open. } - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => NavigationScreen(route: route), - ), - ); + try { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => NavigationScreen(route: route), + ), + ); + } catch (e) { + // Surface navigation errors to the user so they can report/log. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Navigatiefout: ${e.toString()}')), + ); + } }, borderRadius: BorderRadius.circular(20), splashFactory: InkSparkle.splashFactory, diff --git a/transit_api/README.md b/transit_api/README.md index f587c67..fe91ef2 100644 --- a/transit_api/README.md +++ b/transit_api/README.md @@ -96,10 +96,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. - 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 +- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status` - Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. +Voorbeeld: +```bash +curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3" +``` + ### Navigation **POST** `/navigation/route` diff --git a/transit_api/index.js b/transit_api/index.js index 45c5bd0..2ba0914 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -324,10 +324,10 @@ function parseScheduleFilters(query, options = {}) { 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), + driverId: toNumberOrNull(query.driverId ?? query.driver_id ?? query.chauffeurId ?? query.chauffeur_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), + stopId: toNumberOrNull(query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id), statuses, }; } @@ -429,8 +429,9 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map 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, + // Hide driver information in public schedule materialization for privacy + driverId: null, + driverName: null, startTime, endTime, weekdays, @@ -1923,6 +1924,91 @@ app.patch('/driver/schedules/:scheduleId/status', authMiddleware, async (req, re const normalizedStatus = status === 'active' ? 'in_progress' : status; + // Fetch the target schedule row to detect recurring templates + const existing = await db.query(`SELECT * FROM transit.schedules WHERE id = $1`, [scheduleId]); + if (existing.rows.length === 0) { + return res.status(404).json({ error: 'schedule_not_found' }); + } + + const scheduleRow = existing.rows[0]; + const weekdays = parseWeekdaysValue(scheduleRow.weekdays); + + // Allow updating a specific occurrence of a recurring template by passing { date: 'YYYY-MM-DD' } + const requestedDate = req.body.date || null; // optional date key + + if (weekdays && weekdays.length > 0) { + // This is a recurring template. If a date was provided, or the template occurs today, operate on an instance. + let targetDateKey = requestedDate; + if (!targetDateKey) { + const todayKey = new Date().toISOString().slice(0, 10); + if (scheduleOccursOnDate(scheduleRow, todayKey)) { + targetDateKey = todayKey; + } + } + + if (targetDateKey) { + // Build occurrence start/end based on template times + 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); + } + + // Check if a concrete instance already exists for this template/date + const existingInstance = await db.query( + `SELECT * FROM transit.schedules WHERE template_id = $1 AND DATE(start_time) = $2`, + [scheduleId, targetDateKey] + ); + + if (existingInstance.rows.length > 0) { + const upd = await db.query(` + UPDATE transit.schedules SET status = $1, updated_at = NOW() + WHERE id = $2 + RETURNING * + `, [normalizedStatus, existingInstance.rows[0].id]); + return res.json({ schedule: upd.rows[0] }); + } + + // Insert a new instance derived from template + 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 transit.schedules) + INSERT INTO transit.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 transit.schedules) + INSERT INTO transit.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 res.json({ schedule: insertResult.rows[0] }); + } + } + + // Fallback: update the schedule row directly (one-time or when no occurrence date is inferred) const result = await db.query(` UPDATE transit.schedules SET status = $1, updated_at = NOW() @@ -2566,7 +2652,7 @@ app.get('/schedules', async (req, res) => { status: s.status, lineNumber: busLine?.lineNumber || 0, busName: bus?.name || 'Unknown Bus', - driverId: s.driverId || null, + driverId: null, estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, }; })).filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); @@ -2648,7 +2734,8 @@ app.get('/schedules', async (req, res) => { 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, + // Hide driver from public API responses + driverId: null, startTime: row.start_time, endTime: row.end_time, weekdays: row.weekdays ? (typeof row.weekdays === 'string' ? JSON.parse(row.weekdays) : row.weekdays) : [], @@ -3279,7 +3366,68 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware const routeStop = routeStopResult.rows[0]; // Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date) - const occurrenceDateKey = req.query.date || req.body.date || toDateKey(schedule.start_time); + let occurrenceDateKey = req.query.date || req.body.date || toDateKey(schedule.start_time); + + // If this schedule is a recurring template, map to or create a concrete instance for the occurrence date + const weekdays = parseWeekdaysValue(schedule.weekdays); + let instanceScheduleId = scheduleId; + if (weekdays && weekdays.length > 0) { + // If no date provided but template occurs today, use today + 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 transit.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 { + // Create instance from template + 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 transit.schedules) + INSERT INTO transit.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 transit.schedules) + INSERT INTO transit.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()); @@ -3305,7 +3453,7 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware marked_by_user_id = EXCLUDED.marked_by_user_id, created_at = NOW() `, [ - scheduleId, + instanceScheduleId, Number(schedule.bus_line_id), Number(routeStop.stop_id), stopOrder, @@ -3321,7 +3469,7 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware SELECT (SELECT COUNT(*)::int FROM transit.route_stops WHERE bus_line_id = $1) AS total_stops, (SELECT COUNT(*)::int FROM transit.schedule_stop_progress WHERE schedule_id = $2 AND scheduled_passed_date = $3) AS passed_stops - `, [schedule.bus_line_id, scheduleId, occurrenceDateKey]); + `, [schedule.bus_line_id, instanceScheduleId, occurrenceDateKey]); const totalStops = Number(totalsResult.rows[0]?.total_stops || 0); const passedStops = Number(totalsResult.rows[0]?.passed_stops || 0); @@ -3334,7 +3482,7 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware status = $2, end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END WHERE id = $1 - `, [scheduleId, nextStatus, actualPassedAt.toISOString()]); + `, [instanceScheduleId, nextStatus, actualPassedAt.toISOString()]); // Calculate delay live const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000);