Enhance navigation and error handling; implement global navigator and messenger keys, update schedule progress retrieval, and improve error messaging in various services

This commit is contained in:
Sven laptop
2026-05-17 22:29:41 +02:00
parent 0e10757c49
commit 73de0d713e
11 changed files with 430 additions and 63 deletions
+3
View File
@@ -3,6 +3,7 @@ import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart'; import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/app_navigator.dart';
void main() { void main() {
runApp(const MainApp()); runApp(const MainApp());
@@ -15,6 +16,8 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Bus Terminal', title: 'Bus Terminal',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
theme: AppTheme.lightTheme(), theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(), darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
+2 -1
View File
@@ -19,7 +19,8 @@ class User {
} else if (rawUserCode is String) { } else if (rawUserCode is String) {
parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode')); parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode'));
} else { } else {
throw FormatException('Missing userCode'); // If the backend doesn't return a userCode, treat as unknown (-1)
parsedUserCode = -1;
} }
return User( return User(
@@ -27,7 +27,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
void initState() { void initState() {
super.initState(); super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate); _scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 3, vsync: this);
} }
@override @override
@@ -120,12 +120,16 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () async { onPressed: () async {
try { try {
await _scheduleService.updateScheduleStatus(svc.id, 'in_progress'); await _scheduleService.updateScheduleStatus(
svc.id,
'in_progress',
date: svc.startTime,
);
if (!mounted) return; if (!mounted) return;
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
@@ -157,11 +161,18 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
)) ))
.toList(); .toList();
Navigator.of(context).push( try {
MaterialPageRoute( await Navigator.of(context).push(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops), MaterialPageRoute(
), builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
); ),
);
} catch (navErr) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -227,6 +238,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
tabs: const [ tabs: const [
Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'), Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'), Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
Tab(icon: Icon(Icons.timeline), text: 'Voortgang'),
], ],
), ),
actions: [ actions: [
@@ -249,12 +261,131 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
scheduleFuture: _scheduleFuture, scheduleFuture: _scheduleFuture,
), ),
const RecurringSchedulesScreen(), const RecurringSchedulesScreen(),
_ProgressTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
scheduleFuture: _scheduleFuture,
),
], ],
), ),
); );
} }
} }
class _ProgressTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final Future<List<sched.ServiceSchedule>> 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<List<sched.ServiceSchedule>>(
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<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final stops = (progress['stops'] as List<dynamic>?) ?? [];
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<String, dynamic>;
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 { class _DailyScheduleTab extends StatelessWidget {
final ScheduleService scheduleService; final ScheduleService scheduleService;
final DateTime selectedDate; final DateTime selectedDate;
@@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
/// Handle expired authentication: clear stored token and navigate to login.
Future<void> 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 (_) {}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import '../models/user.dart';
/// Authentication Service /// Authentication Service
class AuthService { 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'; static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs; late SharedPreferences _prefs;
+8 -1
View File
@@ -1,8 +1,15 @@
/// Custom exception for authentication errors /// Custom exception for authentication errors
import 'app_navigator.dart';
class UnauthorizedException implements Exception { class UnauthorizedException implements Exception {
final String message; final String message;
UnauthorizedException(this.message); UnauthorizedException(this.message) {
// Trigger centralized logout/navigation asynchronously
try {
handleAuthExpired(message: message);
} catch (_) {}
}
@override @override
String toString() => message; String toString() => message;
@@ -85,7 +85,7 @@ class NavigationService {
throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}'); throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}');
} }
} else { } else {
throw Exception('Failed to get route: ${response.statusCode}'); throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
@@ -110,7 +110,25 @@ class NavigationService {
final response = await http.get(Uri.parse(url)); final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) { 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<String, dynamic>?; final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -165,7 +183,7 @@ class NavigationService {
final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec); final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
return OptimizedRouteResult(route: route, orderedStops: stops); 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<String, dynamic>?; final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -287,7 +305,7 @@ class NavigationService {
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct'); 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)]; return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)];
} else { } else {
throw Exception('Failed to get routes: ${response.statusCode}'); throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
+47 -30
View File
@@ -7,9 +7,7 @@ import 'exceptions.dart';
/// Schedule/Route Service /// Schedule/Route Service
class ScheduleService { class ScheduleService {
// TODO: Implement API connector - configure base URL from env/config static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
// Transit API runs on port 5001
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
final _authService = AuthService(); final _authService = AuthService();
/// Get public schedule overview for a specific date. /// Get public schedule overview for a specific date.
@@ -141,12 +139,28 @@ class ScheduleService {
return getDailySchedule(DateTime.now()); return getDailySchedule(DateTime.now());
} }
/// Get per-date progress for a schedule occurrence (public endpoint)
Future<Map<String, dynamic>> 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<String, dynamic>;
}
throw Exception('Failed to load progress: ${response.statusCode}');
} catch (e) {
throw Exception('Schedule progress error: $e');
}
}
/// Get route details /// Get route details
Future<Route> getRouteDetails(int routeId) async { Future<Route> getRouteDetails(int routeId) async {
try { try {
final headers = await _authService.getAuthHeaders(); final headers = await _authService.getAuthHeaders();
// Transit API exposes route details at /routes/:routeId
final response = await http.get( final response = await http.get(
Uri.parse('$_apiBaseUrl/routes/$routeId'), Uri.parse('$_apiBaseUrl/routes/$routeId'),
headers: headers, headers: headers,
@@ -156,10 +170,10 @@ class ScheduleService {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
return Route.fromJson(data); return Route.fromJson(data);
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else { } 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) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
@@ -180,7 +194,6 @@ class ScheduleService {
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else if (response.statusCode != 200) { } else if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.statusCode}'); throw Exception('Failed to update status: ${response.statusCode}');
@@ -192,18 +205,23 @@ class ScheduleService {
} }
/// Update schedule status for driver-facing schedule workflows. /// Update schedule status for driver-facing schedule workflows.
Future<void> updateScheduleStatus(int scheduleId, String status) async { Future<void> updateScheduleStatus(int scheduleId, String status, {DateTime? date}) async {
try { try {
final headers = await _authService.getAuthHeaders(); final headers = await _authService.getAuthHeaders();
final body = <String, dynamic>{'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( final response = await http.patch(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'), Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'),
headers: headers, headers: headers,
body: jsonEncode({'status': status}), body: jsonEncode(body),
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else if (response.statusCode != 200) { } else if (response.statusCode != 200) {
throw Exception('Failed to update schedule status: ${response.statusCode}'); throw Exception('Failed to update schedule status: ${response.statusCode}');
@@ -226,27 +244,25 @@ class ScheduleService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
final stops = (data['stops'] as List?) final raw = data['stops'] as List? ?? [];
?.map((s) => s as Map<String, dynamic>) final stops = raw.map((s) => s as Map<String, dynamic>).map((s) {
.map((s) { final lat = (s['latitude'] ?? s['lat']) as num?;
final lat = (s['latitude'] ?? s['lat']) as num?; final lon = (s['longitude'] ?? s['lon']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?; final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?; if (lat == null || lon == null) return null;
if (lat == null || lon == null) return null; return NavigationPoint(
return NavigationPoint( latitude: lat.toDouble(),
latitude: lat.toDouble(), longitude: lon.toDouble(),
longitude: lon.toDouble(), name: name,
name: name, );
); }).whereType<NavigationPoint>().toList();
})
.whereType<NavigationPoint>()
.toList() ?? [];
return stops; return stops;
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else { } 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) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
@@ -263,12 +279,13 @@ class ScheduleService {
try { try {
final headers = await _authService.getAuthHeaders(); final headers = await _authService.getAuthHeaders();
final body = <String, dynamic>{};
if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String();
final response = await http.post( final response = await http.post(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'), Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'),
headers: headers, headers: headers,
body: jsonEncode({ body: jsonEncode(body),
if (actualPassedAt != null) 'actualPassedAt': actualPassedAt.toIso8601String(),
}),
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
+12 -5
View File
@@ -67,11 +67,18 @@ class RouteCard extends StatelessWidget {
// Ignore status update errors here; navigation should still open. // Ignore status update errors here; navigation should still open.
} }
Navigator.of(context).push( try {
MaterialPageRoute( await Navigator.of(context).push(
builder: (context) => NavigationScreen(route: route), 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), borderRadius: BorderRadius.circular(20),
splashFactory: InkSparkle.splashFactory, splashFactory: InkSparkle.splashFactory,
+6
View File
@@ -96,10 +96,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
- Haalt een datum-gebonden overzicht op van geplande diensten - Haalt een datum-gebonden overzicht op van geplande diensten
- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays` - Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays`
- Incidentele diensten worden alleen meegenomen op hun exacte datum - Incidentele diensten worden alleen meegenomen op hun exacte datum
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` - Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. 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 ### Navigation
**POST** `/navigation/route` **POST** `/navigation/route`
+158 -10
View File
@@ -324,10 +324,10 @@ function parseScheduleFilters(query, options = {}) {
date: query.date ? String(query.date).trim() : null, date: query.date ? String(query.date).trim() : null,
fromDate: query.fromDate ? String(query.fromDate).trim() : null, fromDate: query.fromDate ? String(query.fromDate).trim() : null,
toDate: query.toDate ? String(query.toDate).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), busId: toNumberOrNull(query.busId ?? query.bus_id),
busLineId: toNumberOrNull(query.busLineId ?? query.lineId ?? query.bus_line_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, statuses,
}; };
} }
@@ -429,8 +429,9 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map
id: Number(row.id), id: Number(row.id),
busLineId, busLineId,
busId: Number(row.busId ?? row.bus_id), busId: Number(row.busId ?? row.bus_id),
driverId: row.driverId !== null && row.driverId !== undefined ? Number(row.driverId) : null, // Hide driver information in public schedule materialization for privacy
driverName: row.driverName ?? row.driver_name ?? null, driverId: null,
driverName: null,
startTime, startTime,
endTime, endTime,
weekdays, weekdays,
@@ -1923,6 +1924,91 @@ app.patch('/driver/schedules/:scheduleId/status', authMiddleware, async (req, re
const normalizedStatus = status === 'active' ? 'in_progress' : status; 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(` const result = await db.query(`
UPDATE transit.schedules UPDATE transit.schedules
SET status = $1, updated_at = NOW() SET status = $1, updated_at = NOW()
@@ -2566,7 +2652,7 @@ app.get('/schedules', async (req, res) => {
status: s.status, status: s.status,
lineNumber: busLine?.lineNumber || 0, lineNumber: busLine?.lineNumber || 0,
busName: bus?.name || 'Unknown Bus', busName: bus?.name || 'Unknown Bus',
driverId: s.driverId || null, driverId: null,
estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
}; };
})).filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)); })).filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
@@ -2648,7 +2734,8 @@ app.get('/schedules', async (req, res) => {
id: Number(row.id), id: Number(row.id),
busLineId: Number(row.bus_line_id), busLineId: Number(row.bus_line_id),
busId: Number(row.bus_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, startTime: row.start_time,
endTime: row.end_time, endTime: row.end_time,
weekdays: row.weekdays ? (typeof row.weekdays === 'string' ? JSON.parse(row.weekdays) : row.weekdays) : [], 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]; const routeStop = routeStopResult.rows[0];
// Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date) // 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 templateStart = new Date(schedule.start_time);
const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`); const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`);
scheduledPassedAt.setHours(templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds()); 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, marked_by_user_id = EXCLUDED.marked_by_user_id,
created_at = NOW() created_at = NOW()
`, [ `, [
scheduleId, instanceScheduleId,
Number(schedule.bus_line_id), Number(schedule.bus_line_id),
Number(routeStop.stop_id), Number(routeStop.stop_id),
stopOrder, stopOrder,
@@ -3321,7 +3469,7 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware
SELECT SELECT
(SELECT COUNT(*)::int FROM transit.route_stops WHERE bus_line_id = $1) AS total_stops, (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 (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 totalStops = Number(totalsResult.rows[0]?.total_stops || 0);
const passedStops = Number(totalsResult.rows[0]?.passed_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, status = $2,
end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END
WHERE id = $1 WHERE id = $1
`, [scheduleId, nextStatus, actualPassedAt.toISOString()]); `, [instanceScheduleId, nextStatus, actualPassedAt.toISOString()]);
// Calculate delay live // Calculate delay live
const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000); const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000);