Public API

This commit is contained in:
Sven laptop
2026-05-08 16:39:59 +02:00
parent d95bbb2db3
commit bf66616c1b
3 changed files with 466 additions and 5 deletions
@@ -92,20 +92,34 @@ class _NavigationScreenState extends State<NavigationScreen> {
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<NavigationScreen> {
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);
});
@@ -177,4 +177,35 @@ class ScheduleService {
throw Exception('Get stops error: $e');
}
}
/// Mark a schedule stop as passed by the driver.
Future<void> 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');
}
}
}
+399
View File
@@ -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}`));