diff --git a/bus_terminal/lib/screens/navigation_screen.dart b/bus_terminal/lib/screens/navigation_screen.dart index e05bbae..5a1d473 100644 --- a/bus_terminal/lib/screens/navigation_screen.dart +++ b/bus_terminal/lib/screens/navigation_screen.dart @@ -92,20 +92,34 @@ class _NavigationScreenState extends State { late NavigationPoint endPoint; if (_stops != null && _stops!.length >= 2) { - final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!); - _stops = optimized.orderedStops; + final hasScheduleStops = widget.initialStops != null; + + if (!hasScheduleStops) { + final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!); + _stops = optimized.orderedStops; + if (mounted) { + setState(() { + _currentRoute = optimized.route; + _alternativeRoutes = null; + _isLoading = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(optimized.route)); + } + return; + } + + final navRoute = await _navigationService.getRouteThroughStops(stops: _stops!); startPoint = _stops!.first; endPoint = _stops!.last; if (mounted) { setState(() { - _currentRoute = optimized.route; - // limit alternatives to first two to avoid rendering many polylines + _currentRoute = navRoute; _alternativeRoutes = null; _isLoading = false; }); - WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(optimized.route)); + WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute)); } return; @@ -286,6 +300,23 @@ class _NavigationScreenState extends State { if (_stops == null || _stops!.isEmpty) return; if (index < 0 || index >= _stops!.length) return; + // When this screen is opened from schedule overview, route.id maps to scheduleId. + if (widget.initialStops != null) { + try { + await _scheduleService.markScheduleStopPassed( + scheduleId: widget.route.id, + stopOrder: index + 1, + actualPassedAt: DateTime.now(), + ); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Stop update mislukt: ${e.toString().replaceAll('Exception: ', '')}'; + }); + } + } + } + setState(() { _completedStops.add(index); }); diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index ea39124..e4a48c6 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -177,4 +177,35 @@ class ScheduleService { throw Exception('Get stops error: $e'); } } + + /// Mark a schedule stop as passed by the driver. + Future markScheduleStopPassed({ + required int scheduleId, + required int stopOrder, + DateTime? actualPassedAt, + }) async { + try { + final headers = await _authService.getAuthHeaders(); + + final response = await http.post( + Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'), + headers: headers, + body: jsonEncode({ + if (actualPassedAt != null) 'actualPassedAt': actualPassedAt.toIso8601String(), + }), + ); + + if (response.statusCode == 401 || response.statusCode == 403) { + await _authService.logout(); + throw UnauthorizedException('Session expired - Please login again'); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Failed to mark stop as passed: ${response.statusCode}'); + } + } catch (e) { + if (e is UnauthorizedException) rethrow; + throw Exception('Mark stop passed error: $e'); + } + } } diff --git a/transit_api/index.js b/transit_api/index.js index 531a56b..44a3333 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -17,12 +17,14 @@ const _mock = { busLines: [], fleetBuses: [], schedules: [], + scheduleStopProgress: [], drivers: [], nextStopId: 1, nextRouteStopId: 1, nextBusLineId: 1, nextFleetBusId: 1, nextScheduleId: 1, + nextScheduleStopProgressId: 1, nextDriverId: 1, }; @@ -249,6 +251,33 @@ function checkBusConflict(busId, startTime, endTime, weekdays, allSchedules, exc }); } +async function ensureScheduleStopProgressTable() { + if (DEV_MOCK) return; + + await db.query(` + CREATE TABLE IF NOT EXISTS transit.schedule_stop_progress ( + id BIGSERIAL PRIMARY KEY, + schedule_id INTEGER NOT NULL, + bus_line_id INTEGER NOT NULL, + stop_id INTEGER NOT NULL, + stop_order INTEGER NOT NULL, + stop_name TEXT, + estimated_arrival_minutes INTEGER NOT NULL DEFAULT 0, + scheduled_passed_at TIMESTAMPTZ NOT NULL, + actual_passed_at TIMESTAMPTZ NOT NULL, + delay_minutes INTEGER NOT NULL DEFAULT 0, + marked_by_user_id INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (schedule_id, stop_order) + ) + `); + + await db.query(` + CREATE INDEX IF NOT EXISTS idx_schedule_stop_progress_schedule_order + ON transit.schedule_stop_progress (schedule_id, stop_order) + `); +} + // ============================================ // ROUTES - Get daily schedule // ============================================ @@ -1947,6 +1976,376 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => { } }); +// ============================================ +// DRIVER - Mark stop as passed (updates realtime state) +// ============================================ +app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware, async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + const stopOrder = Number(req.params.stopOrder); + + if (!Number.isFinite(scheduleId) || !Number.isFinite(stopOrder) || stopOrder < 1) { + return res.status(400).json({ error: 'invalid_schedule_or_stop_order' }); + } + + const actualPassedAt = req.body?.actualPassedAt ? new Date(req.body.actualPassedAt) : new Date(); + if (Number.isNaN(actualPassedAt.getTime())) { + return res.status(400).json({ error: 'invalid_actual_passed_at' }); + } + + if (DEV_MOCK) { + const schedule = (_mock.schedules || []).find(s => Number(s.id) === scheduleId); + if (!schedule) { + return res.status(404).json({ error: 'schedule_not_found' }); + } + + const routeStop = (_mock.routeStops || []) + .find(rs => Number(rs.busLineId) === Number(schedule.busLineId) && Number(rs.stopOrder) === stopOrder); + + if (!routeStop) { + return res.status(404).json({ error: 'stop_not_found_for_schedule' }); + } + + const stop = (_mock.stops || []).find(s => Number(s.id) === Number(routeStop.stopId)); + const scheduledPassedAt = new Date(schedule.startTime); + scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + (Number(routeStop.estimatedArrivalMinutes) || 0)); + const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000); + + const existingIndex = (_mock.scheduleStopProgress || []).findIndex( + p => Number(p.scheduleId) === scheduleId && Number(p.stopOrder) === stopOrder + ); + + const progressRow = { + id: existingIndex >= 0 ? _mock.scheduleStopProgress[existingIndex].id : _mock.nextScheduleStopProgressId++, + scheduleId, + busLineId: Number(schedule.busLineId), + stopId: Number(routeStop.stopId), + stopOrder, + stopName: stop?.name || routeStop.name || 'Unknown Stop', + estimatedArrivalMinutes: Number(routeStop.estimatedArrivalMinutes) || 0, + scheduledPassedAt: scheduledPassedAt.toISOString(), + actualPassedAt: actualPassedAt.toISOString(), + delayMinutes, + markedByUserId: Number(req.user?.userId) || null, + createdAt: new Date().toISOString(), + }; + + if (existingIndex >= 0) { + _mock.scheduleStopProgress[existingIndex] = progressRow; + } else { + _mock.scheduleStopProgress.push(progressRow); + } + + const totalStops = (_mock.routeStops || []).filter(rs => Number(rs.busLineId) === Number(schedule.busLineId)).length; + const passedCount = (_mock.scheduleStopProgress || []).filter(p => Number(p.scheduleId) === scheduleId).length; + + if (passedCount >= totalStops) { + schedule.status = 'completed'; + schedule.endTime = actualPassedAt.toISOString(); + } else if (schedule.status === 'planned') { + schedule.status = 'active'; + } + + return res.json({ + scheduleId, + stopOrder, + status: schedule.status, + delayMinutes, + passedStops: passedCount, + totalStops, + }); + } + + await ensureScheduleStopProgressTable(); + + const scheduleResult = await db.query(` + SELECT s.id, s.bus_line_id, s.start_time, s.status + FROM transit.schedules s + WHERE s.id = $1 + `, [scheduleId]); + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ error: 'schedule_not_found' }); + } + + const schedule = scheduleResult.rows[0]; + + const routeStopResult = await db.query(` + SELECT + rs.stop_id, + rs.stop_order, + rs.estimated_arrival_minutes, + s.name AS stop_name + FROM transit.route_stops rs + JOIN transit.stops s ON s.id = rs.stop_id + WHERE rs.bus_line_id = $1 + AND rs.stop_order = $2 + LIMIT 1 + `, [schedule.bus_line_id, stopOrder]); + + if (routeStopResult.rows.length === 0) { + return res.status(404).json({ error: 'stop_not_found_for_schedule' }); + } + + const routeStop = routeStopResult.rows[0]; + const scheduledPassedAt = new Date(schedule.start_time); + scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + (Number(routeStop.estimated_arrival_minutes) || 0)); + const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000); + + await db.query(` + INSERT INTO transit.schedule_stop_progress ( + schedule_id, + bus_line_id, + stop_id, + stop_order, + stop_name, + estimated_arrival_minutes, + scheduled_passed_at, + actual_passed_at, + delay_minutes, + marked_by_user_id + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (schedule_id, stop_order) + DO UPDATE SET + actual_passed_at = EXCLUDED.actual_passed_at, + delay_minutes = EXCLUDED.delay_minutes, + marked_by_user_id = EXCLUDED.marked_by_user_id, + created_at = NOW() + `, [ + scheduleId, + Number(schedule.bus_line_id), + Number(routeStop.stop_id), + stopOrder, + routeStop.stop_name, + Number(routeStop.estimated_arrival_minutes) || 0, + scheduledPassedAt.toISOString(), + actualPassedAt.toISOString(), + delayMinutes, + Number(req.user?.userId) || null, + ]); + + const totalsResult = await db.query(` + 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) AS passed_stops + `, [schedule.bus_line_id, scheduleId]); + + const totalStops = Number(totalsResult.rows[0]?.total_stops || 0); + const passedStops = Number(totalsResult.rows[0]?.passed_stops || 0); + const isCompleted = totalStops > 0 && passedStops >= totalStops; + const nextStatus = isCompleted ? 'completed' : 'active'; + + await db.query(` + UPDATE transit.schedules + SET + status = $2, + end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END + WHERE id = $1 + `, [scheduleId, nextStatus, actualPassedAt.toISOString()]); + + res.json({ + scheduleId, + stopOrder, + status: nextStatus, + delayMinutes, + passedStops, + totalStops, + }); + } catch (err) { + console.error('POST /driver/schedules/:scheduleId/stops/:stopOrder/passed error:', err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// PUBLIC - Live progress for passengers +// ============================================ +app.get('/public/schedules/:scheduleId/progress', async (req, res) => { + try { + const scheduleId = Number(req.params.scheduleId); + if (!Number.isFinite(scheduleId)) { + return res.status(400).json({ error: 'invalid_schedule_id' }); + } + + if (DEV_MOCK) { + const schedule = (_mock.schedules || []).find(s => Number(s.id) === scheduleId); + if (!schedule) { + return res.status(404).json({ error: 'schedule_not_found' }); + } + + const busLine = (_mock.busLines || []).find(bl => Number(bl.id) === Number(schedule.busLineId)); + const routeStops = (_mock.routeStops || []) + .filter(rs => Number(rs.busLineId) === Number(schedule.busLineId)) + .sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder)); + + const progressEvents = (_mock.scheduleStopProgress || []) + .filter(p => Number(p.scheduleId) === scheduleId) + .sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder)); + + const progressByOrder = new Map(progressEvents.map(p => [Number(p.stopOrder), p])); + const latestProgress = progressEvents.length > 0 ? progressEvents[progressEvents.length - 1] : null; + const currentDelayMinutes = latestProgress ? Number(latestProgress.delayMinutes) || 0 : 0; + + const stops = routeStops.map(rs => { + const stop = (_mock.stops || []).find(s => Number(s.id) === Number(rs.stopId)); + const scheduledTime = new Date(schedule.startTime); + scheduledTime.setMinutes(scheduledTime.getMinutes() + (Number(rs.estimatedArrivalMinutes) || 0)); + const progress = progressByOrder.get(Number(rs.stopOrder)); + + return { + stopId: Number(rs.stopId), + stopOrder: Number(rs.stopOrder), + stopName: stop?.name || rs.name || 'Unknown Stop', + estimatedArrivalMinutes: Number(rs.estimatedArrivalMinutes) || 0, + scheduledPassedAt: scheduledTime.toISOString(), + actualPassedAt: progress?.actualPassedAt || null, + delayMinutes: progress ? Number(progress.delayMinutes) || 0 : null, + status: progress ? 'passed' : 'pending', + }; + }); + + const passedStops = stops.filter(s => s.status === 'passed'); + const nextStop = stops.find(s => s.status === 'pending') || null; + const driverState = passedStops.length === 0 + ? 'not_started' + : (passedStops.length >= stops.length ? 'completed' : 'en_route'); + + return res.json({ + schedule: { + id: scheduleId, + lineNumber: Number(busLine?.lineNumber || 0), + startStop: busLine?.startStop || null, + endStop: busLine?.endStop || null, + startTime: schedule.startTime, + endTime: schedule.endTime, + status: schedule.status, + }, + driverState, + currentDelayMinutes, + passedStopCount: passedStops.length, + totalStopCount: stops.length, + nextStop: nextStop + ? { + ...nextStop, + predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(), + } + : null, + stops, + updatedAt: latestProgress?.actualPassedAt || null, + }); + } + + await ensureScheduleStopProgressTable(); + + const scheduleResult = await db.query(` + SELECT + s.id, + s.bus_line_id, + s.start_time, + s.end_time, + s.status, + bl.line_number, + bl.start_stop, + bl.end_stop + FROM transit.schedules s + JOIN transit.bus_lines bl ON bl.id = s.bus_line_id + WHERE s.id = $1 + LIMIT 1 + `, [scheduleId]); + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ error: 'schedule_not_found' }); + } + + const schedule = scheduleResult.rows[0]; + + const routeStopsResult = await db.query(` + SELECT + rs.stop_id, + rs.stop_order, + rs.estimated_arrival_minutes, + s.name AS stop_name + FROM transit.route_stops rs + JOIN transit.stops s ON s.id = rs.stop_id + WHERE rs.bus_line_id = $1 + ORDER BY rs.stop_order ASC + `, [schedule.bus_line_id]); + + const progressResult = await db.query(` + SELECT + stop_order, + actual_passed_at, + delay_minutes + FROM transit.schedule_stop_progress + WHERE schedule_id = $1 + ORDER BY stop_order ASC + `, [scheduleId]); + + const progressByOrder = new Map( + progressResult.rows.map(row => [Number(row.stop_order), row]) + ); + const latestProgress = progressResult.rows.length > 0 + ? progressResult.rows[progressResult.rows.length - 1] + : null; + + const currentDelayMinutes = latestProgress ? Number(latestProgress.delay_minutes) || 0 : 0; + + const stops = routeStopsResult.rows.map(row => { + const etaMinutes = Number(row.estimated_arrival_minutes) || 0; + const scheduledPassedAt = new Date(schedule.start_time); + scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + etaMinutes); + + const progress = progressByOrder.get(Number(row.stop_order)); + + return { + stopId: Number(row.stop_id), + stopOrder: Number(row.stop_order), + stopName: row.stop_name, + estimatedArrivalMinutes: etaMinutes, + scheduledPassedAt: scheduledPassedAt.toISOString(), + actualPassedAt: progress?.actual_passed_at || null, + delayMinutes: progress ? Number(progress.delay_minutes) || 0 : null, + status: progress ? 'passed' : 'pending', + }; + }); + + const passedStops = stops.filter(s => s.status === 'passed'); + const nextStop = stops.find(s => s.status === 'pending') || null; + + const driverState = passedStops.length === 0 + ? 'not_started' + : (passedStops.length >= stops.length ? 'completed' : 'en_route'); + + res.json({ + schedule: { + id: Number(schedule.id), + lineNumber: Number(schedule.line_number), + startStop: schedule.start_stop, + endStop: schedule.end_stop, + startTime: schedule.start_time, + endTime: schedule.end_time, + status: schedule.status, + }, + driverState, + currentDelayMinutes, + passedStopCount: passedStops.length, + totalStopCount: stops.length, + nextStop: nextStop + ? { + ...nextStop, + predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(), + } + : null, + stops, + updatedAt: latestProgress?.actual_passed_at || null, + }); + } catch (err) { + console.error('GET /public/schedules/:scheduleId/progress error:', err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + const PORT = process.env.PORT || 5001; app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));