diff --git a/admin_ui/README.md b/admin_ui/README.md
index d034bd0..7922d7c 100644
--- a/admin_ui/README.md
+++ b/admin_ui/README.md
@@ -1,3 +1,18 @@
+## Weekly schedules and shifts
+
+The schedule manager uses the weekly template model. Administrators create a
+departure for a bus line in one of three categories: weekdays, Saturday, or
+Sunday. The UI creates the corresponding `day_of_week` rows and assigns drivers
+through `transit.shifts`, rather than storing a driver on `transit.schedules`.
+
+The active UI uses these transit API endpoints:
+
+- `GET /admin/weekly-schedules`
+- `GET /admin/shift-drivers`
+- `POST /admin/weekly-schedules`
+- `PUT /admin/weekly-schedules/:scheduleId/shift`
+- `DELETE /admin/weekly-schedules/:scheduleId`
+
# Transit Admin Control Center
Modern React + Vite web application for comprehensive transit system administration.
@@ -15,6 +30,7 @@ Modern React + Vite web application for comprehensive transit system administrat
## Local Development
### Prerequisites
+
- Node.js 18+
- npm or yarn
@@ -86,9 +102,9 @@ http://localhost:5174
### Environment Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 |
+| Variable | Description | Default |
+| ------------------ | -------------------------------------------------------- | --------------------- |
+| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 |
| `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 |
### Runtime Configuration
@@ -98,6 +114,7 @@ When running in Docker, environment variables are injected at runtime via `docke
## Architecture
### Technology Stack
+
- **Frontend**: React 18.2 + Vite 5.0
- **HTTP Client**: Axios 1.4
- **Server**: Nginx (Alpine Linux)
@@ -116,6 +133,7 @@ When running in Docker, environment variables are injected at runtime via `docke
### API Integration
The app communicates with:
+
- **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log)
@@ -148,16 +166,19 @@ Users must have a `userCode` in their JWT and, for admin access, the `isAdmin` f
## Troubleshooting
### Issue: "Admin UI not loading"
+
- Verify container is running: `docker ps`
- Check logs: `docker-compose logs admin_ui`
- Clear browser cache (Ctrl+Shift+R)
### Issue: "API connection refused"
+
- Ensure auth_api and transit_api are running
- Check environment variables are set correctly
- Verify network connectivity between containers
### Issue: "Permission denied" on admin operations
+
- Verify JWT token includes valid `userCode`
- Check `ADMIN_USER_CODES` environment variable
- Ensure database user has appropriate permissions
@@ -180,6 +201,7 @@ See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide.
## Support
For issues or questions:
+
1. Check service logs: `docker-compose logs admin_ui`
2. Verify API connectivity: Visit API URLs directly
3. Check browser console for client-side errors
@@ -188,4 +210,3 @@ For issues or questions:
## License
Part of the XTransit project
-
diff --git a/admin_ui/SCHEDULE_API.md b/admin_ui/SCHEDULE_API.md
index 4f9ff46..4415cbe 100644
--- a/admin_ui/SCHEDULE_API.md
+++ b/admin_ui/SCHEDULE_API.md
@@ -4,66 +4,149 @@
The Transit API now includes a comprehensive schedule management system with both admin and public endpoints.
-### Admin Endpoints (requires authentication + admin privileges)
+### Weekly Admin Endpoints (requires authentication + admin privileges)
+
+The active admin UI uses weekly schedule templates. `dayOfWeek` follows the
+JavaScript/PostgreSQL convention: `0` Sunday, `1` through `5` weekdays, and `6`
+Saturday. Driver assignment is stored in `transit.shifts` and references
+`workers.users(id)`.
+
+#### Get weekly schedules
-#### Get all schedules
```
-GET /admin/schedules
+GET /admin/weekly-schedules
Response: { schedules: [...] }
```
-#### Create a schedule
+#### Get shift drivers
+
```
+GET /admin/shift-drivers
+Response: { drivers: [{ id, name }] }
+```
+
+#### Create a weekly schedule
+
+````
+
+#### Create multiple departures
+
+```text
+POST /admin/weekly-schedules/bulk
+Body: {
+ busLineId: 1,
+ dayOfWeeks: [1, 2, 3, 4, 5],
+ startTimes: ["06:00", "06:30", "07:00"],
+ driverId: 12
+}
+````
+
+The API creates one schedule row for every day/time combination and derives the
+end time from the bus line's configured duration.
+POST /admin/weekly-schedules
+Body: {
+busLineId: 1,
+dayOfWeek: 1,
+startTime: "08:00",
+endTime: "09:00",
+driverId: 12
+}
+
+```
+
+#### Replace a schedule's driver
+
+```
+
+PUT /admin/weekly-schedules/:scheduleId/shift
+Body: { driverId: 12 }
+
+```
+
+Use `{ "driverId": null }` to unassign the driver. The current `shifts` table
+does not contain a week/date column, so this is an active assignment rather than
+historical per-week assignment. Add `week_start` to support that later.
+
+### Legacy Admin Endpoints
+
+#### Get all schedules
+
+```
+
+GET /admin/schedules
+Response: { schedules: [...] }
+
+```
+
+#### Create a schedule
+
+```
+
POST /admin/schedules
Body: {
- busLineId: number,
- busId: number,
- driverId: number (optional),
- startTime: "2026-05-06T08:00:00",
- endTime: "2026-05-06T17:00:00"
+busLineId: number,
+busId: number,
+driverId: number (optional),
+startTime: "2026-05-06T08:00:00",
+endTime: "2026-05-06T17:00:00"
}
Response: { schedule: {...} }
+
```
#### Delete a schedule
+
```
+
DELETE /admin/schedules/:scheduleId
Response: { success: true, schedule: {...} }
+
```
### Public Endpoints (no authentication required)
#### Get schedules (with optional filters)
+
```
+
GET /schedules?date=2026-05-06&lineId=1
Query params:
- - date: YYYY-MM-DD (optional)
- - lineId: number (optional)
-Response: { schedules: [...] }
+
+- date: YYYY-MM-DD (optional)
+- lineId: number (optional)
+ Response: { schedules: [...] }
+
```
#### Get schedule detail
+
```
+
GET /schedules/:scheduleId
Response: { schedule: {...} }
+
```
#### Get stops for a schedule's line
+
```
+
GET /schedules/:scheduleId/stops
Response: { stops: [...] }
Shows all stops on the bus line with order, location, and ETA.
-```
+
+````
## Admin UI Features
The ScheduleMaker component provides:
-1. **Service Creation Form**
- - Select bus line, vehicle, date, and time
- - Optional driver ID assignment
- - Submit to create schedule
- - Admin-only access
+1. **Weekly schedule editor**
+
+- Select a bus line and one of weekdays, Saturday, or Sunday
+- Define start and end times
+- Assign drivers through shifts
+- Admin-only access
2. **Timetable View**
- 24-hour time slots (hourly grouping)
@@ -86,12 +169,13 @@ The ScheduleMaker component provides:
The TimetableViewer component provides a public-facing schedule display:
```jsx
-import TimetableViewer from './components/TimetableViewer'
+import TimetableViewer from "./components/TimetableViewer";
-
-```
+;
+````
Features:
+
- Filter by date and bus line
- Real-time loading state
- Error handling
@@ -104,6 +188,7 @@ Features:
With `DEV_MOCK=1`, all schedule operations work in-memory without a database.
Example:
+
```bash
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
```
@@ -114,14 +199,19 @@ ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api s
CREATE TABLE transit.schedules (
id BIGINT PRIMARY KEY,
bus_line_id BIGINT REFERENCES transit.bus_lines(id),
- bus_id BIGINT REFERENCES fleet.bus(id),
- driver_id INTEGER,
- start_time TIMESTAMP,
- end_time TIMESTAMP,
+ start_time TIME,
+ end_time TIME,
status VARCHAR(20) DEFAULT 'planned',
+ day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
+
+CREATE TABLE transit.shifts (
+ id BIGINT PRIMARY KEY,
+ schedule_id BIGINT REFERENCES transit.schedules(id) ON DELETE CASCADE,
+ driver_id BIGINT REFERENCES workers.users(id)
+);
```
## Styling & UX
@@ -129,6 +219,7 @@ CREATE TABLE transit.schedules (
The schedule manager uses:
### Admin Timetable Styling
+
- Responsive table with hourly time slots
- Gradient schedule cards (blue/indigo theme)
- Hover effects and smooth transitions
@@ -137,6 +228,7 @@ The schedule manager uses:
- Confirmation modals with semi-transparent overlay
### Public Timetable Styling
+
- Clean, simple table layout
- Status badges (green for active, gray for planned)
- Mobile-responsive with scrolling
@@ -152,6 +244,7 @@ The schedule manager uses:
## Example Usage
### Create a schedule (Admin)
+
```bash
curl -X POST http://localhost:5001/admin/schedules \
-H "Authorization: Bearer YOUR_TOKEN" \
@@ -166,11 +259,13 @@ curl -X POST http://localhost:5001/admin/schedules \
```
### Get today's schedules (Public)
+
```bash
curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d)
```
### Get schedules for specific line (Public)
+
```bash
curl http://localhost:5001/schedules?lineId=3
```
@@ -184,4 +279,3 @@ curl http://localhost:5001/schedules?lineId=3
- Schedule conflict detection
- Integration with GPS tracking for actual vs scheduled times
- Historical schedule analytics
-
diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx
index 565ed88..b50c7ba 100644
--- a/admin_ui/src/components/ScheduleMaker.jsx
+++ b/admin_ui/src/components/ScheduleMaker.jsx
@@ -1,93 +1,61 @@
-import React, { useEffect, useState } from 'react'
-import { transitClient } from '../api'
-import TimetableDisplay from './TimetableDisplay'
-import FixedScheduleEditor from './FixedScheduleEditor'
+import React, { useEffect, useState } from "react";
+import { transitClient } from "../api";
+import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
-export default function ScheduleMaker({ token, user }) {
- const client = transitClient(token)
- const [scheduleTemplates, setScheduleTemplates] = useState([])
- const [busLines, setBusLines] = useState([])
- const [buses, setBuses] = useState([])
- const [drivers, setDrivers] = useState([])
- const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
- const [message, setMessage] = useState(null)
- const [isAdmin] = useState(Boolean(user?.isAdmin))
- const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
+export default function ScheduleMaker({ token }) {
+ const client = transitClient(token);
+ const [schedules, setSchedules] = useState([]);
+ const [busLines, setBusLines] = useState([]);
+ const [drivers, setDrivers] = useState([]);
+ const [message, setMessage] = useState(null);
- const loadScheduleTemplates = async () => {
+ const loadSchedules = async () => {
try {
- const res = await client.get('/admin/schedules?templates=1')
- setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0))
+ const res = await client.get("/admin/weekly-schedules");
+ setSchedules(res.data.schedules || []);
} catch (err) {
- setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message))
+ setMessage("Could not load weekly schedules.");
}
- }
+ };
const loadBusLines = async () => {
try {
- const res = await client.get('/admin/bus-lines')
- setBusLines(res.data.busLines || [])
+ const res = await client.get("/admin/bus-lines");
+ setBusLines(res.data.busLines || []);
} catch (err) {
- setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
+ setMessage("Could not load bus lines.");
}
- }
-
- const loadBuses = async () => {
- try {
- const res = await client.get('/admin/fleet/buses')
- setBuses(res.data.buses || [])
- } catch (err) {
- setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
- }
- }
+ };
const loadDrivers = async () => {
try {
- const res = await client.get('/admin/drivers')
- setDrivers(res.data.drivers || [])
+ const res = await client.get("/admin/shift-drivers");
+ setDrivers(res.data.drivers || []);
} catch (err) {
- setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message))
+ setMessage("Could not load drivers.");
}
- }
+ };
useEffect(() => {
- loadScheduleTemplates()
- loadBusLines()
- loadBuses()
- loadDrivers()
- }, [])
+ loadSchedules();
+ loadBusLines();
+ loadDrivers();
+ }, []);
const handleScheduleChange = async () => {
- await loadScheduleTemplates()
- }
+ await loadSchedules();
+ };
return (
- {/* Timetable widget - compact daily view */}
-
-
- {/* Fixed service editor - separate widget */}
- {isAdmin && (
-
- )}
+ {message &&
{message}
}
- )
+ );
}
diff --git a/admin_ui/src/components/WeeklyScheduleEditor.jsx b/admin_ui/src/components/WeeklyScheduleEditor.jsx
new file mode 100644
index 0000000..d702cfe
--- /dev/null
+++ b/admin_ui/src/components/WeeklyScheduleEditor.jsx
@@ -0,0 +1,268 @@
+import React, { useMemo, useState } from "react";
+
+const DAY_GROUPS = [
+ { value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] },
+ { value: "saturday", label: "Saturday", days: [6] },
+ { value: "sunday", label: "Sunday", days: [0] },
+];
+
+const dayLabel = (day) =>
+ [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ ][day];
+
+export default function WeeklyScheduleEditor({
+ client,
+ busLines,
+ drivers,
+ schedules,
+ onChange,
+}) {
+ const [lineId, setLineId] = useState("");
+ const [category, setCategory] = useState("weekdays");
+ const [startTimes, setStartTimes] = useState(["08:00"]);
+ const [newStartTime, setNewStartTime] = useState("");
+ const [driverId, setDriverId] = useState("");
+ const [message, setMessage] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const groupedSchedules = useMemo(() => {
+ return schedules.reduce((groups, schedule) => {
+ const key =
+ schedule.dayOfWeek >= 1 && schedule.dayOfWeek <= 5
+ ? "weekdays"
+ : schedule.dayOfWeek === 6
+ ? "saturday"
+ : "sunday";
+ groups[key] = [...(groups[key] || []), schedule];
+ return groups;
+ }, {});
+ }, [schedules]);
+
+ const createSchedules = async (event) => {
+ event.preventDefault();
+ setMessage(null);
+ if (!lineId || startTimes.length === 0) {
+ setMessage("Choose a line and add at least one departure time.");
+ return;
+ }
+
+ const group = DAY_GROUPS.find((item) => item.value === category);
+ setSaving(true);
+ try {
+ const response = await client.post("/admin/weekly-schedules/bulk", {
+ busLineId: Number(lineId),
+ dayOfWeeks: group.days,
+ startTimes,
+ driverId: driverId ? Number(driverId) : null,
+ });
+ setMessage(
+ `${response.data.createdCount} departures created for ${group.label}.`,
+ );
+ setStartTimes(["08:00"]);
+ setNewStartTime("");
+ onChange();
+ } catch (error) {
+ setMessage(error.response?.data?.error || "Could not save the schedule.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const addStartTime = () => {
+ if (!newStartTime || startTimes.includes(newStartTime)) return;
+ setStartTimes([...startTimes, newStartTime].sort());
+ setNewStartTime("");
+ };
+
+ const removeStartTime = (time) => {
+ setStartTimes(startTimes.filter((item) => item !== time));
+ };
+
+ const assignDriver = async (scheduleId, value) => {
+ try {
+ await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
+ driverId: value ? Number(value) : null,
+ });
+ setMessage("Driver assignment updated.");
+ onChange();
+ } catch (error) {
+ setMessage(
+ error.response?.data?.error ||
+ "Could not update the driver assignment.",
+ );
+ }
+ };
+
+ const removeSchedule = async (scheduleId) => {
+ if (!window.confirm("Delete this weekly schedule?")) return;
+ try {
+ await client.delete(`/admin/weekly-schedules/${scheduleId}`);
+ setMessage("Weekly schedule deleted.");
+ onChange();
+ } catch (error) {
+ setMessage(
+ error.response?.data?.error || "Could not delete the schedule.",
+ );
+ }
+ };
+
+ return (
+
+
+
+
Weekly schedules
+
+ Add multiple daily departures for weekdays, Saturday, or Sunday,
+ then assign a driver to the generated templates.
+
+
+
+
+
+
+ {message && {message}
}
+
+ {DAY_GROUPS.map((group) => (
+
+
{group.label}
+ {(groupedSchedules[group.value] || []).length === 0 ? (
+
No schedules configured.
+ ) : (
+
+ {groupedSchedules[group.value].map((schedule) => (
+
+
+ Line {schedule.lineNumber}
+
+ {String(schedule.startTime).slice(0, 5)} -{" "}
+ {String(schedule.endTime).slice(0, 5)}
+
+
+
+ {dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
+ {schedule.endStop}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/transit_api/README.md b/transit_api/README.md
index 9433513..c9fe502 100644
--- a/transit_api/README.md
+++ b/transit_api/README.md
@@ -61,9 +61,56 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
### Schedule
+Schedules are weekly templates. Each row in `transit.schedules` represents one
+departure time for one bus line and one `day_of_week`:
+
+- `1` to `5`: weekdays
+- `6`: Saturday
+- `0`: Sunday
+
+Driver assignments are stored separately in `transit.shifts` with
+`(schedule_id, driver_id)`, where `driver_id` references `workers.users(id)`.
+The current `shifts` shape represents the active assignment. To retain different
+assignments for different calendar weeks, add a `week_start` (or assignment date
+range) column and include it in the unique key.
+
+**GET** `/driver/schedules/daily?date=2026-09-18`
+
+- Returns only weekly schedule templates assigned to the authenticated driver through `transit.shifts`.
+
+**PATCH** `/driver/weekly-schedules/:scheduleId/status`
+
+- Updates the status of an assigned weekly schedule.
+
+**GET** `/admin/weekly-schedules`
+
+- Lists weekly templates and their current driver assignments.
+
+**GET** `/admin/shift-drivers`
+
+- Lists drivers from `workers.users` for shift assignment.
+
+**POST** `/admin/weekly-schedules`
+
+- Creates one template. Body: `{ "busLineId": 1, "dayOfWeek": 1, "startTime": "08:00", "endTime": "09:00", "driverId": 12 }`.
+
+**POST** `/admin/weekly-schedules/bulk`
+
+- Creates multiple departures in one transaction. The API derives each end time
+ from the selected bus line duration. Body: `{ "busLineId": 1, "dayOfWeeks": [1,2,3,4,5], "startTimes": ["06:00", "06:30", "07:00"], "driverId": 12 }`.
+
+**PUT** `/admin/weekly-schedules/:scheduleId/shift`
+
+- Replaces the driver assignment. Pass `{ "driverId": 12 }` or `{ "driverId": null }`.
+
+The legacy `/admin/schedules` and `/schedule/daily` endpoints remain for older
+clients, but new clients must use the weekly schedule endpoints above.
+
**GET** `/schedule/daily?date=2024-05-05`
+
- Ophalen van dagplanning voor chauffeur
- Response:
+
```json
{
"date": "2024-05-05",
@@ -88,14 +135,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
```
**GET** `/routes/:routeId`
+
- Details van een specifieke route
- Response: `{ "route": {...} }`
### Public Schedule Overview
**GET** `/schedules/overview?date=2026-05-06&lineId=1`
+
- Haalt een datum-gebonden overzicht op van geplande diensten
-- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays`
+- Weekly templates are selected by `day_of_week` and driver assignments by `shifts`
- Incidentele diensten worden alleen meegenomen op hun exacte datum
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
@@ -103,6 +152,7 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
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"
```
@@ -110,8 +160,10 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
### Navigation
**POST** `/navigation/route`
+
- Opslaan van berekende route van OSRM
- Body:
+
```json
{
"routeId": 1,
@@ -129,8 +181,10 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
```
**GET** `/bus-lines/:busLineId/stops`
+
- Alle stops van een buslijn (voor navigatie waypoints)
- Response:
+
```json
{
"stops": [
@@ -138,8 +192,8 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
"id": 1,
"order": 1,
"name": "Central Station",
- "latitude": 52.5200,
- "longitude": 13.4050
+ "latitude": 52.52,
+ "longitude": 13.405
}
]
}
@@ -148,13 +202,15 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
### GPS Tracking
**POST** `/gps/track`
+
- Real-time positie opslaan
- Body:
+
```json
{
"routeId": 1,
- "latitude": 52.5210,
- "longitude": 13.4060,
+ "latitude": 52.521,
+ "longitude": 13.406,
"speed": 45.5,
"heading": 180,
"accuracy": 5.0
@@ -162,15 +218,17 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
```
**GET** `/gps/route/:routeId`
+
- Alle opgeslagen positie punten van een route
- Response:
+
```json
{
"positions": [
{
"id": 1,
- "latitude": 52.5210,
- "longitude": 13.4060,
+ "latitude": 52.521,
+ "longitude": 13.406,
"speed": 45.5,
"heading": 180,
"recordedAt": "2024-05-05T08:15:00Z"
@@ -182,6 +240,7 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
### Route Status
**PATCH** `/routes/:routeId/status`
+
- Update route status
- Body: `{ "status": "in_progress" }`
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
@@ -191,19 +250,23 @@ curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&bu
Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` voorkomt in `ADMIN_USER_CODES`.
**POST** `/admin/stops`
+
- Maak een herbruikbare stop aan
- Body:
+
```json
{
"name": "Central Station",
- "latitude": 52.5200,
- "longitude": 13.4050
+ "latitude": 52.52,
+ "longitude": 13.405
}
```
**POST** `/admin/bus-lines/:busLineId/stops`
+
- Koppel een bestaande stop aan een buslijn
- Body:
+
```json
{
"stopId": 1,
@@ -215,12 +278,14 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
## Database Schema
### bus_types
+
- `id` - Primary key
- `name` - Bus type name (Standard Bus, etc.)
- `seat_capacity` - Number of seats
- `license_plate` - Unique license plate
### bus_lines
+
- `id` - Primary key
- `line_number` - Unique line number (1, 5, 12, etc.)
- `start_stop` - Start stop name
@@ -228,11 +293,13 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `estimated_duration_minutes` - Expected route duration
### stops
+
- `id` - Primary key
- `name` - Stop name
- `latitude`, `longitude` - Stop location
### route_stops
+
- `id` - Primary key
- `bus_line_id` - Which bus line the stop belongs to
- `stop_id` - Reusable stop reference
@@ -240,6 +307,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `estimated_arrival_minutes` - Optional ETA per stop
### routes
+
- `id` - Primary key
- `user_id` - Chauffeur (references workers.users)
- `bus_line_id` - Which line (references bus_lines)
@@ -248,6 +316,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `status` - scheduled/in_progress/completed/cancelled
### navigation_routes
+
- `id` - Primary key
- `route_id` - Link to route (unique)
- `polyline_geojson` - GeoJSON LineString van de route
@@ -255,6 +324,7 @@ Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` v
- `total_duration_seconds` - Estimated duration
### gps_tracking
+
- `id` - Primary key
- `route_id` - Which route
- `latitude`, `longitude` - Current position
@@ -287,6 +357,7 @@ await scheduleService.updateRouteStatus(1, 'in_progress');
## Error Handling
Alle endpoints retourneren error responses:
+
```json
{
"error": "error_code",
@@ -295,6 +366,7 @@ Alle endpoints retourneren error responses:
```
Common errors:
+
- `missing_token` - No Authorization header
- `invalid_token` - Token verification failed
- `missing_required_fields` - Required data missing
diff --git a/transit_api/index.js b/transit_api/index.js
index c1ead49..6dff590 100644
--- a/transit_api/index.js
+++ b/transit_api/index.js
@@ -1,31 +1,32 @@
-const express = require('express');
-const cors = require('cors');
-require('dotenv').config();
+const express = require("express");
+const cors = require("cors");
+require("dotenv").config();
-const db = require('./db');
-const { authMiddleware } = require('./middleware');
+const db = require("./db");
+const { authMiddleware } = require("./middleware");
const app = express();
app.use(express.json());
app.use(cors());
-const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
+const ERROR_LOG_API_URL =
+ process.env.ERROR_LOG_API_URL || "http://localhost:5003/error_log";
function friendlyErrorMessage(statusCode) {
- if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
- if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
- if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
- if (statusCode === 404) return 'Niet gevonden.';
- return 'Er ging iets mis. Probeer het later opnieuw.';
+ if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
+ if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
+ if (statusCode === 403) return "Je hebt hier geen toegang toe.";
+ if (statusCode === 404) return "Niet gevonden.";
+ return "Er ging iets mis. Probeer het later opnieuw.";
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- service: 'transit_api',
+ service: "transit_api",
partOfService,
error,
}),
@@ -36,9 +37,13 @@ async function reportError({ partOfService, error }) {
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
- if (res.statusCode >= 400 && body && typeof body === 'object') {
- const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
- void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
+ if (res.statusCode >= 400 && body && typeof body === "object") {
+ const rawError =
+ body.error || body.message || body.details || `HTTP ${res.statusCode}`;
+ void reportError({
+ partOfService: `${req.method} ${req.originalUrl}`,
+ error: rawError,
+ });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
@@ -48,7 +53,7 @@ app.use((req, res, next) => {
});
// Development mock storage when DEV_MOCK=1
-const DEV_MOCK = process.env.DEV_MOCK === '1';
+const DEV_MOCK = process.env.DEV_MOCK === "1";
const _mock = {
stops: [],
routeStops: [],
@@ -69,40 +74,140 @@ const _mock = {
// Initialize mock drivers
if (DEV_MOCK) {
_mock.drivers = [
- { id: 1, name: 'Jan Peterse', email: 'jan@transit.local', phone: '06-12345678', status: 'active' },
- { id: 2, name: 'Maria Kuijpers', email: 'maria@transit.local', phone: '06-87654321', status: 'active' },
- { id: 3, name: 'Robert Smits', email: 'robert@transit.local', phone: '06-55555555', status: 'active' },
- { id: 4, name: 'Anja Voorn', email: 'anja@transit.local', phone: '06-44444444', status: 'inactive' },
+ {
+ id: 1,
+ name: "Jan Peterse",
+ email: "jan@transit.local",
+ phone: "06-12345678",
+ status: "active",
+ },
+ {
+ id: 2,
+ name: "Maria Kuijpers",
+ email: "maria@transit.local",
+ phone: "06-87654321",
+ status: "active",
+ },
+ {
+ id: 3,
+ name: "Robert Smits",
+ email: "robert@transit.local",
+ phone: "06-55555555",
+ status: "active",
+ },
+ {
+ id: 4,
+ name: "Anja Voorn",
+ email: "anja@transit.local",
+ phone: "06-44444444",
+ status: "inactive",
+ },
];
_mock.nextDriverId = 5;
// Initialize test bus lines
_mock.busLines = [
- { id: 1, lineNumber: 1, startStop: 'Central Station', endStop: 'Airport', estimatedDurationMinutes: 45, description: 'Main route' },
- { id: 2, lineNumber: 2, startStop: 'Central Station', endStop: 'Harbor', estimatedDurationMinutes: 30, description: 'Harbor route' },
+ {
+ id: 1,
+ lineNumber: 1,
+ startStop: "Central Station",
+ endStop: "Airport",
+ estimatedDurationMinutes: 45,
+ description: "Main route",
+ },
+ {
+ id: 2,
+ lineNumber: 2,
+ startStop: "Central Station",
+ endStop: "Harbor",
+ estimatedDurationMinutes: 30,
+ description: "Harbor route",
+ },
];
_mock.nextBusLineId = 3;
// Initialize test stops
_mock.stops = [
- { id: 1, name: 'Central Station', latitude: 52.0116, longitude: 4.7055, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
- { id: 2, name: 'City Center', latitude: 52.0125, longitude: 4.7100, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
- { id: 3, name: 'Airport', latitude: 52.0300, longitude: 4.7500, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
+ {
+ id: 1,
+ name: "Central Station",
+ latitude: 52.0116,
+ longitude: 4.7055,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: 2,
+ name: "City Center",
+ latitude: 52.0125,
+ longitude: 4.71,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: 3,
+ name: "Airport",
+ latitude: 52.03,
+ longitude: 4.75,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
];
_mock.nextStopId = 4;
// Initialize test route stops
_mock.routeStops = [
- { id: 1, busLineId: 1, stopId: 1, stopOrder: 1, estimatedArrivalMinutes: 0, name: 'Central Station', latitude: 52.0116, longitude: 4.7055 },
- { id: 2, busLineId: 1, stopId: 2, stopOrder: 2, estimatedArrivalMinutes: 15, name: 'City Center', latitude: 52.0125, longitude: 4.7100 },
- { id: 3, busLineId: 1, stopId: 3, stopOrder: 3, estimatedArrivalMinutes: 45, name: 'Airport', latitude: 52.0300, longitude: 4.7500 },
+ {
+ id: 1,
+ busLineId: 1,
+ stopId: 1,
+ stopOrder: 1,
+ estimatedArrivalMinutes: 0,
+ name: "Central Station",
+ latitude: 52.0116,
+ longitude: 4.7055,
+ },
+ {
+ id: 2,
+ busLineId: 1,
+ stopId: 2,
+ stopOrder: 2,
+ estimatedArrivalMinutes: 15,
+ name: "City Center",
+ latitude: 52.0125,
+ longitude: 4.71,
+ },
+ {
+ id: 3,
+ busLineId: 1,
+ stopId: 3,
+ stopOrder: 3,
+ estimatedArrivalMinutes: 45,
+ name: "Airport",
+ latitude: 52.03,
+ longitude: 4.75,
+ },
];
_mock.nextRouteStopId = 4;
// Initialize test fleet buses
_mock.fleetBuses = [
- { id: 1, name: 'Bus 101', seatCapacity: 45, licensePlate: 'AB-01-CD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
- { id: 2, name: 'Bus 102', seatCapacity: 50, licensePlate: 'AB-02-CD', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
+ {
+ id: 1,
+ name: "Bus 101",
+ seatCapacity: 45,
+ licensePlate: "AB-01-CD",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: 2,
+ name: "Bus 102",
+ seatCapacity: 50,
+ licensePlate: "AB-02-CD",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
];
_mock.nextFleetBusId = 3;
@@ -121,7 +226,7 @@ if (DEV_MOCK) {
startTime: tomorrow.toISOString(),
endTime: endTime.toISOString(),
weekdays: [],
- status: 'planned',
+ status: "planned",
createdAt: new Date().toISOString(),
},
];
@@ -130,7 +235,7 @@ if (DEV_MOCK) {
const adminMiddleware = (req, res, next) => {
if (!req.user || !req.user.isAdmin) {
- return res.status(403).json({ error: 'forbidden' });
+ return res.status(403).json({ error: "forbidden" });
}
next();
@@ -146,22 +251,24 @@ const adminMiddleware = (req, res, next) => {
*/
function calculateDepartureTimes(startTime, busLineId, mock) {
const startDate = new Date(startTime);
-
+
// Get route stops for this bus line
const routeStops = mock.routeStops
- .filter(rs => rs.busLineId === busLineId)
+ .filter((rs) => rs.busLineId === busLineId)
.sort((a, b) => a.stopOrder - b.stopOrder);
- return routeStops.map(rs => {
+ return routeStops.map((rs) => {
const departureDate = new Date(startDate);
// Add estimated arrival minutes to get departure time
- departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0));
-
- const stop = mock.stops.find(s => s.id === rs.stopId);
+ departureDate.setMinutes(
+ departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0),
+ );
+
+ const stop = mock.stops.find((s) => s.id === rs.stopId);
return {
order: rs.stopOrder,
stopId: rs.stopId,
- stopName: stop?.name || 'Unknown Stop',
+ stopName: stop?.name || "Unknown Stop",
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
departureTime: departureDate.toISOString(),
};
@@ -170,34 +277,35 @@ function calculateDepartureTimes(startTime, busLineId, mock) {
function parseWeekdaysValue(value) {
if (!value) return [];
- if (Array.isArray(value)) return value.map(v => Number(v)).filter(v => Number.isFinite(v));
+ if (Array.isArray(value))
+ return value.map((v) => Number(v)).filter((v) => Number.isFinite(v));
- if (typeof value === 'string') {
+ if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
- return parsed.map(v => Number(v)).filter(v => Number.isFinite(v));
+ return parsed.map((v) => Number(v)).filter((v) => Number.isFinite(v));
}
} catch (_) {
// Fall through to CSV/PG-array parsing.
}
// Handles values like "1,2,3" or "{1,2,3}".
- const normalized = trimmed.replace('{', '').replace('}', '');
+ const normalized = trimmed.replace("{", "").replace("}", "");
return normalized
- .split(',')
- .map(v => Number(v.trim()))
- .filter(v => Number.isFinite(v));
+ .split(",")
+ .map((v) => Number(v.trim()))
+ .filter((v) => Number.isFinite(v));
}
return [];
}
function toNumberOrNull(value) {
- if (value === undefined || value === null || value === '') return null;
+ if (value === undefined || value === null || value === "") return null;
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
@@ -205,12 +313,12 @@ function toNumberOrNull(value) {
function parseCommaSeparatedValues(value) {
if (!value && value !== 0) return [];
if (Array.isArray(value)) {
- return value.map(item => String(item).trim()).filter(Boolean);
+ return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
- .split(',')
- .map(item => item.trim())
+ .split(",")
+ .map((item) => item.trim())
.filter(Boolean);
}
@@ -240,7 +348,7 @@ function expandRecurringScheduleRows(rows) {
const templateDayOfWeek = templateDate.getDay();
const sortedWeekdays = [...new Set(weekdays)].sort((a, b) => a - b);
const durationMinutes = Number(
- row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60
+ row.estimated_duration_minutes ?? row.estimatedDurationMinutes ?? 60,
);
for (let week = 0; week < 52; week++) {
@@ -279,7 +387,10 @@ function mapAdminScheduleRow(row, 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,
+ driverId:
+ row.driverId !== null && row.driverId !== undefined
+ ? Number(row.driverId)
+ : null,
driverName: row.driverName ?? row.driver_name ?? null,
startTime,
endTime: row.endTime ?? row.end_time,
@@ -287,7 +398,7 @@ function mapAdminScheduleRow(row, routeStopsByLine = new Map()) {
departureTimes,
status: row.status,
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
- busName: row.busName ?? row.bus_name ?? 'Unknown Bus',
+ busName: row.busName ?? row.bus_name ?? "Unknown Bus",
licensePlate: row.licensePlate ?? row.license_plate ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
isRecurringInstance: row.is_recurring_instance || false,
@@ -297,13 +408,17 @@ function mapAdminScheduleRow(row, routeStopsByLine = new Map()) {
};
}
-function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) {
+function scheduleMatchesFilters(
+ schedule,
+ filters,
+ routeStopsByLine = new Map(),
+) {
if (!schedule || !filters) return true;
const scheduleBusLineId = Number(schedule.bus_line_id ?? schedule.busLineId);
const scheduleBusId = Number(schedule.bus_id ?? schedule.busId);
const scheduleDriverId = Number(schedule.driver_id ?? schedule.driverId);
- const scheduleStatus = String(schedule.status || '');
+ const scheduleStatus = String(schedule.status || "");
const scheduleDateKey = toDateKey(schedule.start_time ?? schedule.startTime);
if (filters.busLineId !== null && scheduleBusLineId !== filters.busLineId) {
@@ -318,7 +433,10 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map())
return false;
}
- if (filters.statuses.length > 0 && !filters.statuses.includes(scheduleStatus)) {
+ if (
+ filters.statuses.length > 0 &&
+ !filters.statuses.includes(scheduleStatus)
+ ) {
return false;
}
@@ -326,7 +444,11 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map())
return false;
}
- if (filters.fromDate && scheduleDateKey && scheduleDateKey < filters.fromDate) {
+ if (
+ filters.fromDate &&
+ scheduleDateKey &&
+ scheduleDateKey < filters.fromDate
+ ) {
return false;
}
@@ -336,7 +458,10 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map())
if (filters.stopId !== null) {
const routeStops = routeStopsByLine.get(scheduleBusLineId) || [];
- const matchesStop = routeStops.some(routeStop => Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId);
+ const matchesStop = routeStops.some(
+ (routeStop) =>
+ Number(routeStop.stop_id ?? routeStop.stopId) === filters.stopId,
+ );
if (!matchesStop) {
return false;
}
@@ -346,50 +471,65 @@ function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map())
}
function parseScheduleFilters(query, options = {}) {
- const statuses = parseCommaSeparatedValues(query.status || query.statuses || options.statuses || []);
+ const statuses = parseCommaSeparatedValues(
+ query.status || query.statuses || options.statuses || [],
+ );
return {
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 ?? query.chauffeurId ?? query.chauffeur_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 ?? query.busStopId ?? query.bus_stop_id),
+ busLineId: toNumberOrNull(
+ query.busLineId ?? query.lineId ?? query.bus_line_id,
+ ),
+ stopId: toNumberOrNull(
+ query.stopId ?? query.stop_id ?? query.busStopId ?? query.bus_stop_id,
+ ),
statuses,
};
}
function formatWeekdayLabel(weekdays) {
- const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
+ const labels = ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"];
if (!Array.isArray(weekdays) || weekdays.length === 0) {
- return 'Eenmalig';
+ return "Eenmalig";
}
if (weekdays.length === 7) {
- return 'Dagelijks';
+ return "Dagelijks";
}
return [...new Set(weekdays)]
.sort((a, b) => a - b)
- .filter(day => day >= 0 && day < labels.length)
- .map(day => labels[day])
- .join(', ');
+ .filter((day) => day >= 0 && day < labels.length)
+ .map((day) => labels[day])
+ .join(", ");
}
function buildDepartureTimes(startTime, routeStops = []) {
const startDate = new Date(startTime);
- return routeStops.map(routeStop => {
+ return routeStops.map((routeStop) => {
const departureDate = new Date(startDate);
- const etaMinutes = Number(routeStop.estimated_arrival_minutes ?? routeStop.estimatedArrivalMinutes) || 0;
+ const etaMinutes =
+ Number(
+ routeStop.estimated_arrival_minutes ??
+ routeStop.estimatedArrivalMinutes,
+ ) || 0;
departureDate.setMinutes(departureDate.getMinutes() + etaMinutes);
return {
order: Number(routeStop.stop_order ?? routeStop.stopOrder),
stopId: Number(routeStop.stop_id ?? routeStop.stopId),
- stopName: routeStop.stop_name || routeStop.name || 'Unknown Stop',
+ stopName: routeStop.stop_name || routeStop.name || "Unknown Stop",
estimatedArrivalMinutes: etaMinutes,
departureTime: departureDate.toISOString(),
};
@@ -422,7 +562,11 @@ function scheduleOccursOnDate(schedule, dateKey) {
return weekdays.includes(targetDate.getDay());
}
-function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map()) {
+function materializeScheduleOverviewRow(
+ row,
+ dateKey,
+ routeStopsByLine = new Map(),
+) {
const weekdays = parseWeekdaysValue(row.weekdays);
const recurring = weekdays.length > 0;
const templateStart = new Date(row.startTime ?? row.start_time);
@@ -436,15 +580,20 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map
templateStart.getHours(),
templateStart.getMinutes(),
templateStart.getSeconds(),
- templateStart.getMilliseconds()
+ templateStart.getMilliseconds(),
);
startTime = occurrenceStart.toISOString();
- if (Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime())) {
+ if (
+ Number.isFinite(templateEnd.getTime()) &&
+ Number.isFinite(templateStart.getTime())
+ ) {
const durationMillis = templateEnd.getTime() - templateStart.getTime();
const occurrenceEnd = new Date(occurrenceStart);
- occurrenceEnd.setMilliseconds(occurrenceEnd.getMilliseconds() + durationMillis);
+ occurrenceEnd.setMilliseconds(
+ occurrenceEnd.getMilliseconds() + durationMillis,
+ );
endTime = occurrenceEnd.toISOString();
}
}
@@ -465,10 +614,10 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map
weekdays,
status: row.status,
lineNumber: Number(row.lineNumber ?? row.line_number) || null,
- busName: row.busName ?? row.bus_name ?? 'Unknown Bus',
+ busName: row.busName ?? row.bus_name ?? "Unknown Bus",
licensePlate: row.licensePlate ?? row.license_plate ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
- scheduleType: recurring ? 'recurring' : 'one_time',
+ scheduleType: recurring ? "recurring" : "one_time",
recurrenceLabel: formatWeekdayLabel(weekdays),
isRecurringInstance: recurring && Boolean(dateKey),
templateId: row.templateId ?? row.template_id ?? null,
@@ -478,12 +627,19 @@ function materializeScheduleOverviewRow(row, dateKey, routeStopsByLine = new Map
function mapRecurringOccurrenceRows(rows, dateKey, routeStopsByLine) {
return rows
- .filter(row => scheduleOccursOnDate(row, dateKey))
- .map(row => materializeScheduleOverviewRow(row, dateKey, routeStopsByLine))
+ .filter((row) => scheduleOccursOnDate(row, dateKey))
+ .map((row) =>
+ materializeScheduleOverviewRow(row, dateKey, routeStopsByLine),
+ )
.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
}
-function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStopsByLine) {
+function materializeScheduleRowsInRange(
+ rows,
+ fromDateKey,
+ toDateKey,
+ routeStopsByLine,
+) {
const occurrences = [];
if (!fromDateKey || !toDateKey) return occurrences;
@@ -501,7 +657,9 @@ function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStops
if (!weekdays || weekdays.length === 0) {
const rowDateKey = toDateKey(row.start_time ?? row.startTime);
if (rowDateKey && rowDateKey >= fromDateKey && rowDateKey <= toDateKey) {
- occurrences.push(materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine));
+ occurrences.push(
+ materializeScheduleOverviewRow(row, rowDateKey, routeStopsByLine),
+ );
}
continue;
}
@@ -518,103 +676,152 @@ function materializeScheduleRowsInRange(rows, fromDateKey, toDateKey, routeStops
// ensure cursor >= templateStart (so recurrence doesn't start before template)
if (cursor < templateStart) {
// advance by weeks until >= templateStart
- const diffDays = Math.ceil((templateStart - cursor) / (24 * 60 * 60 * 1000));
+ const diffDays = Math.ceil(
+ (templateStart - cursor) / (24 * 60 * 60 * 1000),
+ );
const weeksToAdvance = Math.ceil(diffDays / 7);
cursor.setDate(cursor.getDate() + weeksToAdvance * 7);
}
while (cursor <= toDate) {
const occurrenceKey = cursor.toISOString().slice(0, 10);
- occurrences.push(materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine));
+ occurrences.push(
+ materializeScheduleOverviewRow(row, occurrenceKey, routeStopsByLine),
+ );
cursor.setDate(cursor.getDate() + 7);
}
}
}
// sort and return
- return occurrences.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+ return occurrences.sort(
+ (a, b) => new Date(a.startTime) - new Date(b.startTime),
+ );
}
/**
* Check if two schedules have date/weekday overlap
*/
-function doSchedulesDateOverlap(schedule1Start, schedule1End, schedule1Weekdays, schedule2Start, schedule2End, schedule2Weekdays) {
+function doSchedulesDateOverlap(
+ schedule1Start,
+ schedule1End,
+ schedule1Weekdays,
+ schedule2Start,
+ schedule2End,
+ schedule2Weekdays,
+) {
const s1Start = new Date(schedule1Start);
const s1End = new Date(schedule1End);
const s2Start = new Date(schedule2Start);
const s2End = new Date(schedule2End);
-
+
// Check time overlap
if (s1End <= s2Start || s2End <= s1Start) {
return false; // No time overlap
}
-
+
// If both have no weekdays (one-time), check if same day
- if ((!schedule1Weekdays || schedule1Weekdays.length === 0) &&
- (!schedule2Weekdays || schedule2Weekdays.length === 0)) {
+ if (
+ (!schedule1Weekdays || schedule1Weekdays.length === 0) &&
+ (!schedule2Weekdays || schedule2Weekdays.length === 0)
+ ) {
return s1Start.toDateString() === s2Start.toDateString();
}
-
+
// If one has weekdays and other doesn't, check if one-time falls on weekday
- if (schedule1Weekdays && schedule1Weekdays.length > 0 &&
- (!schedule2Weekdays || schedule2Weekdays.length === 0)) {
+ if (
+ schedule1Weekdays &&
+ schedule1Weekdays.length > 0 &&
+ (!schedule2Weekdays || schedule2Weekdays.length === 0)
+ ) {
const s2Day = s2Start.getDay(); // 0=Sun, 1=Mon, etc.
const s2Adjusted = s2Day === 0 ? 0 : s2Day; // Normalize to 0-6
return schedule1Weekdays.includes(s2Adjusted);
}
-
- if ((!schedule1Weekdays || schedule1Weekdays.length === 0) &&
- schedule2Weekdays && schedule2Weekdays.length > 0) {
+
+ if (
+ (!schedule1Weekdays || schedule1Weekdays.length === 0) &&
+ schedule2Weekdays &&
+ schedule2Weekdays.length > 0
+ ) {
const s1Day = s1Start.getDay();
const s1Adjusted = s1Day === 0 ? 0 : s1Day;
return schedule2Weekdays.includes(s1Adjusted);
}
-
+
// Both have weekdays: check if any day overlaps
if (schedule1Weekdays && schedule2Weekdays) {
- return schedule1Weekdays.some(d => schedule2Weekdays.includes(d));
+ return schedule1Weekdays.some((d) => schedule2Weekdays.includes(d));
}
-
+
return false;
}
/**
* Check if a driver has conflicts with existing schedules
*/
-function checkDriverConflict(driverId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) {
+function checkDriverConflict(
+ driverId,
+ startTime,
+ endTime,
+ weekdays,
+ allSchedules,
+ excludeScheduleId = null,
+) {
if (!driverId) return false; // No conflict if no driver specified
-
- return allSchedules.some(s => {
+
+ return allSchedules.some((s) => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sDriverId = s.driverId ?? s.driver_id;
if (Number(sDriverId) !== Number(driverId)) return false;
-
+
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
-
- return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays);
+
+ return doSchedulesDateOverlap(
+ startTime,
+ endTime,
+ wkdays,
+ sStart,
+ sEnd,
+ sWeekdays,
+ );
});
}
/**
* Check if a bus has conflicts with existing schedules
*/
-function checkBusConflict(busId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) {
+function checkBusConflict(
+ busId,
+ startTime,
+ endTime,
+ weekdays,
+ allSchedules,
+ excludeScheduleId = null,
+) {
if (!busId) return false;
-
- return allSchedules.some(s => {
+
+ return allSchedules.some((s) => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sBusId = s.busId ?? s.bus_id;
if (Number(sBusId) !== Number(busId)) return false;
-
+
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
-
- return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays);
+
+ return doSchedulesDateOverlap(
+ startTime,
+ endTime,
+ wkdays,
+ sStart,
+ sEnd,
+ sWeekdays,
+ );
});
}
@@ -695,16 +902,17 @@ async function ensureScheduleStopProgressTable() {
// ============================================
// ROUTES - Get daily schedule
// ============================================
-app.get('/schedule/daily', authMiddleware, async (req, res) => {
+app.get("/schedule/daily", authMiddleware, async (req, res) => {
try {
const { date } = req.query;
const userId = req.user.userId;
if (!date) {
- return res.status(400).json({ error: 'date query parameter required' });
+ return res.status(400).json({ error: "date query parameter required" });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
r.id,
r.start_time,
@@ -725,9 +933,11 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => {
JOIN fleet.bus bt ON r.bus_type_id = bt.id
WHERE r.user_id = $1 AND DATE(r.start_time) = $2
ORDER BY r.start_time ASC
- `, [userId, date]);
+ `,
+ [userId, date],
+ );
- let routes = result.rows.map(row => ({
+ let routes = result.rows.map((row) => ({
id: row.id,
startTime: row.start_time,
endTime: row.end_time,
@@ -748,12 +958,13 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => {
},
}));
- // If no explicit "routes" rows exist for this user/date, fall back to
- // checking scheduled runs in `transit.schedules` (filtered by driver_id).
- // This works around cases where DB permissions prevent inserting into
- // `transit.routes` but schedules exist and should be shown to the driver.
- if (!DEV_MOCK && routes.length === 0) {
- const fallback = await db.query(`
+ // If no explicit "routes" rows exist for this user/date, fall back to
+ // checking scheduled runs in `transit.schedules` (filtered by driver_id).
+ // This works around cases where DB permissions prevent inserting into
+ // `transit.routes` but schedules exist and should be shown to the driver.
+ if (!DEV_MOCK && routes.length === 0) {
+ const fallback = await db.query(
+ `
SELECT
s.id,
s.start_time,
@@ -775,94 +986,152 @@ app.get('/schedule/daily', authMiddleware, async (req, res) => {
JOIN fleet.bus bt ON s.bus_id = bt.id
WHERE s.driver_id = $1 AND DATE(s.start_time) = $2
ORDER BY s.start_time ASC
- `, [userId, date]);
+ `,
+ [userId, date],
+ );
- routes = fallback.rows.map(row => ({
- id: row.id,
- startTime: row.start_time,
- endTime: row.end_time,
- status: row.status,
- busLine: {
- id: row.bus_line_id,
- lineNumber: row.line_number,
- startStop: row.start_stop,
- endStop: row.end_stop,
- estimatedDuration: row.estimated_duration_minutes,
- description: row.description,
- },
- busType: {
- id: row.bus_type_id,
- name: row.bus_type,
- seatCapacity: row.seat_capacity,
- licensePlate: row.license_plate,
- },
- }));
- }
+ routes = fallback.rows.map((row) => ({
+ id: row.id,
+ startTime: row.start_time,
+ endTime: row.end_time,
+ status: row.status,
+ busLine: {
+ id: row.bus_line_id,
+ lineNumber: row.line_number,
+ startStop: row.start_stop,
+ endStop: row.end_stop,
+ estimatedDuration: row.estimated_duration_minutes,
+ description: row.description,
+ },
+ busType: {
+ id: row.bus_type_id,
+ name: row.bus_type,
+ seatCapacity: row.seat_capacity,
+ licensePlate: row.license_plate,
+ },
+ }));
+ }
- // If still empty, try to synthesize a single route by shifting a recent
- // schedule's time-of-day to the requested date. This is a non-destructive
- // fallback to make sure drivers see a trip when DB inserts are restricted.
- if (!DEV_MOCK && routes.length === 0) {
- const tmplRes = await db.query(`
+ // If still empty, try to synthesize a single route by shifting a recent
+ // schedule's time-of-day to the requested date. This is a non-destructive
+ // fallback to make sure drivers see a trip when DB inserts are restricted.
+ if (!DEV_MOCK && routes.length === 0) {
+ const tmplRes = await db.query(
+ `
SELECT s.start_time, s.end_time, s.status, s.bus_line_id, s.bus_id, s.driver_id, bl.estimated_duration_minutes, bl.line_number, bl.start_stop, bl.end_stop, bl.description
FROM transit.schedules s
JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
WHERE s.driver_id = $1
ORDER BY s.start_time DESC
LIMIT 1
- `, [userId]);
+ `,
+ [userId],
+ );
- if (tmplRes.rowCount > 0) {
- const t = tmplRes.rows[0];
- const requestedDate = new Date(date + 'T00:00:00.000Z');
- const srcStart = new Date(t.start_time);
- const srcEnd = new Date(t.end_time);
+ if (tmplRes.rowCount > 0) {
+ const t = tmplRes.rows[0];
+ const requestedDate = new Date(date + "T00:00:00.000Z");
+ const srcStart = new Date(t.start_time);
+ const srcEnd = new Date(t.end_time);
- // Preserve time-of-day from template, apply to requested date
- const newStart = new Date(requestedDate);
- newStart.setUTCHours(srcStart.getUTCHours(), srcStart.getUTCMinutes(), srcStart.getUTCSeconds(), srcStart.getUTCMilliseconds());
- const durationMs = srcEnd.getTime() - srcStart.getTime();
- const newEnd = new Date(newStart.getTime() + durationMs);
+ // Preserve time-of-day from template, apply to requested date
+ const newStart = new Date(requestedDate);
+ newStart.setUTCHours(
+ srcStart.getUTCHours(),
+ srcStart.getUTCMinutes(),
+ srcStart.getUTCSeconds(),
+ srcStart.getUTCMilliseconds(),
+ );
+ const durationMs = srcEnd.getTime() - srcStart.getTime();
+ const newEnd = new Date(newStart.getTime() + durationMs);
- routes.push({
- id: null,
- startTime: newStart.toISOString(),
- endTime: newEnd.toISOString(),
- status: t.status || 'planned',
- busLine: {
- id: t.bus_line_id,
- lineNumber: t.line_number,
- startStop: t.start_stop,
- endStop: t.end_stop,
- estimatedDuration: t.estimated_duration_minutes,
- description: t.description,
- },
- busType: {
- id: t.bus_id,
- name: null,
- seatCapacity: null,
- licensePlate: null,
- },
- });
- }
+ routes.push({
+ id: null,
+ startTime: newStart.toISOString(),
+ endTime: newEnd.toISOString(),
+ status: t.status || "planned",
+ busLine: {
+ id: t.bus_line_id,
+ lineNumber: t.line_number,
+ startStop: t.start_stop,
+ endStop: t.end_stop,
+ estimatedDuration: t.estimated_duration_minutes,
+ description: t.description,
+ },
+ busType: {
+ id: t.bus_id,
+ name: null,
+ seatCapacity: null,
+ licensePlate: null,
+ },
+ });
}
+ }
res.json({ date, routes });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+});
+
+// DRIVER - Weekly schedule templates assigned through transit.shifts.
+// day_of_week uses JavaScript/PostgreSQL convention: 0 Sunday, 1 Monday ... 6 Saturday.
+app.get("/driver/schedules/daily", authMiddleware, async (req, res) => {
+ try {
+ const { date } = req.query;
+ if (!date)
+ return res.status(400).json({ error: "date query parameter required" });
+
+ const result = await db.query(
+ `
+ SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week,
+ bl.line_number, bl.start_stop, bl.end_stop, bl.estimated_duration_minutes,
+ bl.description
+ FROM transit.schedules s
+ JOIN transit.shifts sh ON sh.schedule_id = s.id AND sh.driver_id = $1
+ JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
+ WHERE s.day_of_week = EXTRACT(DOW FROM $2::date)::int
+ AND s.status <> 'cancelled'
+ ORDER BY s.start_time ASC
+ `,
+ [req.user.userId, date],
+ );
+
+ res.json({
+ date,
+ routes: result.rows.map((row) => ({
+ id: Number(row.id),
+ startTime: row.start_time,
+ endTime: row.end_time,
+ status: row.status,
+ dayOfWeek: Number(row.day_of_week),
+ busLine: {
+ id: Number(row.bus_line_id),
+ lineNumber: row.line_number,
+ startStop: row.start_stop,
+ endStop: row.end_stop,
+ estimatedDuration: row.estimated_duration_minutes,
+ description: row.description,
+ },
+ })),
+ });
+ } catch (err) {
+ console.error("GET /driver/schedules/daily error:", err);
+ res.status(500).json({ error: "internal_error" });
}
});
// ============================================
// ROUTES - Get single route details
// ============================================
-app.get('/routes/:routeId', authMiddleware, async (req, res) => {
+app.get("/routes/:routeId", authMiddleware, async (req, res) => {
try {
const { routeId } = req.params;
const userId = req.user.userId;
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
r.id,
r.start_time,
@@ -882,10 +1151,12 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => {
JOIN bus_lines bl ON r.bus_line_id = bl.id
JOIN bus_types bt ON r.bus_type_id = bt.id
WHERE r.id = $1 AND r.user_id = $2
- `, [routeId, userId]);
+ `,
+ [routeId, userId],
+ );
if (result.rows.length === 0) {
- return res.status(404).json({ error: 'route_not_found' });
+ return res.status(404).json({ error: "route_not_found" });
}
const row = result.rows[0];
@@ -913,21 +1184,21 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => {
res.json({ route });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ROUTES - Get stops for a bus line
// ============================================
-app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
+app.get("/bus-lines/:busLineId/stops", authMiddleware, async (req, res) => {
try {
const { busLineId } = req.params;
if (DEV_MOCK) {
const stops = _mock.routeStops
- .filter(rs => String(rs.busLineId) === String(busLineId))
- .sort((a,b) => a.stopOrder - b.stopOrder)
- .map(rs => ({
+ .filter((rs) => String(rs.busLineId) === String(busLineId))
+ .sort((a, b) => a.stopOrder - b.stopOrder)
+ .map((rs) => ({
id: rs.id,
order: rs.stopOrder,
name: rs.name,
@@ -938,7 +1209,8 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
return res.json({ stops });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
rs.id,
rs.stop_order,
@@ -950,9 +1222,11 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
- `, [busLineId]);
+ `,
+ [busLineId],
+ );
- const stops = result.rows.map(row => ({
+ const stops = result.rows.map((row) => ({
id: row.id,
order: row.stop_order,
name: row.stop_name,
@@ -964,17 +1238,19 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
res.json({ stops });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ADMIN - Create reusable stop
// ============================================
-app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
+app.get("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
- return res.json({ stops: _mock.stops.slice().sort((a, b) => a.id - b.id) });
+ return res.json({
+ stops: _mock.stops.slice().sort((a, b) => a.id - b.id),
+ });
}
const result = await db.query(`
@@ -984,7 +1260,7 @@ app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
`);
res.json({
- stops: result.rows.map(row => ({
+ stops: result.rows.map((row) => ({
id: row.id,
name: row.name,
latitude: parseFloat(row.latitude),
@@ -995,16 +1271,16 @@ app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
-app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
+app.post("/admin/stops", authMiddleware, adminMiddleware, async (req, res) => {
try {
const { name, latitude, longitude } = req.body;
if (!name || latitude === undefined || longitude === undefined) {
- return res.status(400).json({ error: 'missing_required_fields' });
+ return res.status(400).json({ error: "missing_required_fields" });
}
if (DEV_MOCK) {
@@ -1020,7 +1296,8 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
return res.status(201).json({ stop });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.stops
)
@@ -1028,7 +1305,9 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
SELECT next_id.id, $1, $2, $3
FROM next_id
RETURNING id, name, latitude, longitude, created_at, updated_at
- `, [name, latitude, longitude]);
+ `,
+ [name, latitude, longitude],
+ );
const stop = result.rows[0];
res.status(201).json({
@@ -1043,65 +1322,90 @@ app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ADMIN - Manage bus lines
// ============================================
-app.get('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- if (DEV_MOCK) {
- return res.json({ busLines: _mock.busLines.slice().sort((a, b) => a.lineNumber - b.lineNumber) });
- }
+app.get(
+ "/admin/bus-lines",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ if (DEV_MOCK) {
+ return res.json({
+ busLines: _mock.busLines
+ .slice()
+ .sort((a, b) => a.lineNumber - b.lineNumber),
+ });
+ }
- const result = await db.query(`
+ const result = await db.query(`
SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description
FROM transit.bus_lines
ORDER BY line_number ASC, id ASC
`);
- res.json({
- busLines: result.rows.map(row => ({
- id: row.id,
- lineNumber: row.line_number,
- startStop: row.start_stop,
- endStop: row.end_stop,
- estimatedDurationMinutes: row.estimated_duration_minutes,
- description: row.description,
- })),
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body;
-
- if (lineNumber === undefined || !startStop || !endStop || estimatedDurationMinutes === undefined) {
- return res.status(400).json({ error: 'missing_required_fields' });
+ res.json({
+ busLines: result.rows.map((row) => ({
+ id: row.id,
+ lineNumber: row.line_number,
+ startStop: row.start_stop,
+ endStop: row.end_stop,
+ estimatedDurationMinutes: row.estimated_duration_minutes,
+ description: row.description,
+ })),
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
+ },
+);
- if (DEV_MOCK) {
- const busLine = {
- id: _mock.nextBusLineId++,
- lineNumber: Number(lineNumber),
+app.post(
+ "/admin/bus-lines",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const {
+ lineNumber,
startStop,
endStop,
- estimatedDurationMinutes: Number(estimatedDurationMinutes),
- description: description || '',
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- };
- _mock.busLines.push(busLine);
- return res.status(201).json({ busLine });
- }
+ estimatedDurationMinutes,
+ description,
+ } = req.body;
- const result = await db.query(`
+ if (
+ lineNumber === undefined ||
+ !startStop ||
+ !endStop ||
+ estimatedDurationMinutes === undefined
+ ) {
+ return res.status(400).json({ error: "missing_required_fields" });
+ }
+
+ if (DEV_MOCK) {
+ const busLine = {
+ id: _mock.nextBusLineId++,
+ lineNumber: Number(lineNumber),
+ startStop,
+ endStop,
+ estimatedDurationMinutes: Number(estimatedDurationMinutes),
+ description: description || "",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ _mock.busLines.push(busLine);
+ return res.status(201).json({ busLine });
+ }
+
+ const result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.bus_lines
)
@@ -1109,43 +1413,67 @@ app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) =
SELECT next_id.id, $1, $2, $3, $4, $5
FROM next_id
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
- `, [lineNumber, startStop, endStop, estimatedDurationMinutes, description || null]);
+ `,
+ [
+ lineNumber,
+ startStop,
+ endStop,
+ estimatedDurationMinutes,
+ description || null,
+ ],
+ );
- const busLine = result.rows[0];
- res.status(201).json({
- busLine: {
- id: busLine.id,
- lineNumber: busLine.line_number,
- startStop: busLine.start_stop,
- endStop: busLine.end_stop,
- estimatedDurationMinutes: busLine.estimated_duration_minutes,
- description: busLine.description,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busLineId } = req.params;
- const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body;
-
- if (DEV_MOCK) {
- const existing = _mock.busLines.find(line => String(line.id) === String(busLineId));
- if (!existing) return res.status(404).json({ error: 'bus_line_not_found' });
- if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber);
- if (startStop !== undefined) existing.startStop = startStop;
- if (endStop !== undefined) existing.endStop = endStop;
- if (estimatedDurationMinutes !== undefined) existing.estimatedDurationMinutes = Number(estimatedDurationMinutes);
- if (description !== undefined) existing.description = description;
- existing.updatedAt = new Date().toISOString();
- return res.json({ busLine: existing });
+ const busLine = result.rows[0];
+ res.status(201).json({
+ busLine: {
+ id: busLine.id,
+ lineNumber: busLine.line_number,
+ startStop: busLine.start_stop,
+ endStop: busLine.end_stop,
+ estimatedDurationMinutes: busLine.estimated_duration_minutes,
+ description: busLine.description,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
+ },
+);
- const result = await db.query(`
+app.patch(
+ "/admin/bus-lines/:busLineId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busLineId } = req.params;
+ const {
+ lineNumber,
+ startStop,
+ endStop,
+ estimatedDurationMinutes,
+ description,
+ } = req.body;
+
+ if (DEV_MOCK) {
+ const existing = _mock.busLines.find(
+ (line) => String(line.id) === String(busLineId),
+ );
+ if (!existing)
+ return res.status(404).json({ error: "bus_line_not_found" });
+ if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber);
+ if (startStop !== undefined) existing.startStop = startStop;
+ if (endStop !== undefined) existing.endStop = endStop;
+ if (estimatedDurationMinutes !== undefined)
+ existing.estimatedDurationMinutes = Number(estimatedDurationMinutes);
+ if (description !== undefined) existing.description = description;
+ existing.updatedAt = new Date().toISOString();
+ return res.json({ busLine: existing });
+ }
+
+ const result = await db.query(
+ `
UPDATE transit.bus_lines
SET
line_number = COALESCE($1, line_number),
@@ -1155,100 +1483,135 @@ app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async
description = COALESCE($5, description)
WHERE id = $6
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
- `, [lineNumber ?? null, startStop ?? null, endStop ?? null, estimatedDurationMinutes ?? null, description ?? null, busLineId]);
+ `,
+ [
+ lineNumber ?? null,
+ startStop ?? null,
+ endStop ?? null,
+ estimatedDurationMinutes ?? null,
+ description ?? null,
+ busLineId,
+ ],
+ );
- if (!result.rows[0]) return res.status(404).json({ error: 'bus_line_not_found' });
+ if (!result.rows[0])
+ return res.status(404).json({ error: "bus_line_not_found" });
- const busLine = result.rows[0];
- res.json({
- busLine: {
- id: busLine.id,
- lineNumber: busLine.line_number,
- startStop: busLine.start_stop,
- endStop: busLine.end_stop,
- estimatedDurationMinutes: busLine.estimated_duration_minutes,
- description: busLine.description,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ const busLine = result.rows[0];
+ res.json({
+ busLine: {
+ id: busLine.id,
+ lineNumber: busLine.line_number,
+ startStop: busLine.start_stop,
+ endStop: busLine.end_stop,
+ estimatedDurationMinutes: busLine.estimated_duration_minutes,
+ description: busLine.description,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Manage fleet buses
// ============================================
-app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- if (DEV_MOCK) {
- const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id);
- return res.json({
- buses: buses.map(b => ({
- ...b,
- available: !checkBusConflict(b.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || [])
- }))
- });
- }
+app.get(
+ "/admin/fleet/buses",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ if (DEV_MOCK) {
+ const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id);
+ return res.json({
+ buses: buses.map((b) => ({
+ ...b,
+ available: !checkBusConflict(
+ b.id,
+ new Date().toISOString(),
+ new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+ [],
+ _mock.schedules || [],
+ ),
+ })),
+ });
+ }
- const result = await db.query(`
+ const result = await db.query(`
SELECT id, name, seat_capacity, license_plate
FROM fleet.bus
ORDER BY id ASC
`);
- // Get all schedules to check for conflicts
- const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`);
- const schedules = schedulesResult.rows.map(row => ({
- ...row,
- weekdays: parseWeekdaysValue(row.weekdays),
- }));
+ // Get all schedules to check for conflicts
+ const schedulesResult = await db.query(
+ `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
+ );
+ const schedules = schedulesResult.rows.map((row) => ({
+ ...row,
+ weekdays: parseWeekdaysValue(row.weekdays),
+ }));
- res.json({
- buses: result.rows.map(row => {
- const busId = row.id;
- const now = new Date();
- const tomorrow = new Date(now);
- tomorrow.setDate(tomorrow.getDate() + 1);
- const isAvailable = !checkBusConflict(busId, now.toISOString(), tomorrow.toISOString(), [], schedules);
-
- return {
- id: row.id,
- name: row.name,
- seatCapacity: row.seat_capacity,
- licensePlate: row.license_plate,
- available: isAvailable,
+ res.json({
+ buses: result.rows.map((row) => {
+ const busId = row.id;
+ const now = new Date();
+ const tomorrow = new Date(now);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const isAvailable = !checkBusConflict(
+ busId,
+ now.toISOString(),
+ tomorrow.toISOString(),
+ [],
+ schedules,
+ );
+
+ return {
+ id: row.id,
+ name: row.name,
+ seatCapacity: row.seat_capacity,
+ licensePlate: row.license_plate,
+ available: isAvailable,
+ };
+ }),
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
+
+app.post(
+ "/admin/fleet/buses",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { name, seatCapacity, licensePlate } = req.body;
+
+ if (!name || seatCapacity === undefined || !licensePlate) {
+ return res.status(400).json({ error: "missing_required_fields" });
+ }
+
+ if (DEV_MOCK) {
+ const bus = {
+ id: _mock.nextFleetBusId++,
+ name,
+ seatCapacity: Number(seatCapacity),
+ licensePlate,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
};
- }),
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ _mock.fleetBuses.push(bus);
+ return res.status(201).json({ bus });
+ }
-app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { name, seatCapacity, licensePlate } = req.body;
-
- if (!name || seatCapacity === undefined || !licensePlate) {
- return res.status(400).json({ error: 'missing_required_fields' });
- }
-
- if (DEV_MOCK) {
- const bus = {
- id: _mock.nextFleetBusId++,
- name,
- seatCapacity: Number(seatCapacity),
- licensePlate,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- };
- _mock.fleetBuses.push(bus);
- return res.status(201).json({ bus });
- }
-
- const result = await db.query(`
+ const result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM fleet.bus
)
@@ -1256,116 +1619,153 @@ app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res)
SELECT next_id.id, $1, $2, $3
FROM next_id
RETURNING id, name, seat_capacity, license_plate
- `, [name, seatCapacity, licensePlate]);
+ `,
+ [name, seatCapacity, licensePlate],
+ );
- const bus = result.rows[0];
- res.status(201).json({
- bus: {
- id: bus.id,
- name: bus.name,
- seatCapacity: bus.seat_capacity,
- licensePlate: bus.license_plate,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ const bus = result.rows[0];
+ res.status(201).json({
+ bus: {
+ id: bus.id,
+ name: bus.name,
+ seatCapacity: bus.seat_capacity,
+ licensePlate: bus.license_plate,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Update fleet bus
// ============================================
-app.patch('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busId } = req.params;
- const { name, seatCapacity, licensePlate } = req.body;
+app.patch(
+ "/admin/fleet/buses/:busId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busId } = req.params;
+ const { name, seatCapacity, licensePlate } = req.body;
- if (!busId || !Number.isFinite(Number(busId))) {
- return res.status(400).json({ error: 'invalid_bus_id' });
- }
+ if (!busId || !Number.isFinite(Number(busId))) {
+ return res.status(400).json({ error: "invalid_bus_id" });
+ }
- if (DEV_MOCK) {
- const bus = _mock.fleetBuses.find(b => Number(b.id) === Number(busId));
- if (!bus) return res.status(404).json({ error: 'bus_not_found' });
- if (name !== undefined) bus.name = name;
- if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity);
- if (licensePlate !== undefined) bus.licensePlate = licensePlate;
- bus.updatedAt = new Date().toISOString();
- return res.json({ bus });
- }
+ if (DEV_MOCK) {
+ const bus = _mock.fleetBuses.find(
+ (b) => Number(b.id) === Number(busId),
+ );
+ if (!bus) return res.status(404).json({ error: "bus_not_found" });
+ if (name !== undefined) bus.name = name;
+ if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity);
+ if (licensePlate !== undefined) bus.licensePlate = licensePlate;
+ bus.updatedAt = new Date().toISOString();
+ return res.json({ bus });
+ }
- const result = await db.query(`
+ const result = await db.query(
+ `
UPDATE fleet.bus
SET name = COALESCE($1, name), seat_capacity = COALESCE($2, seat_capacity), license_plate = COALESCE($3, license_plate), updated_at = NOW()
WHERE id = $4
RETURNING id, name, seat_capacity, license_plate
- `, [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId]);
+ `,
+ [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId],
+ );
- if (!result.rows[0]) return res.status(404).json({ error: 'bus_not_found' });
+ if (!result.rows[0])
+ return res.status(404).json({ error: "bus_not_found" });
- const bus = result.rows[0];
- res.json({ bus: { id: bus.id, name: bus.name, seatCapacity: bus.seat_capacity, licensePlate: bus.license_plate } });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ const bus = result.rows[0];
+ res.json({
+ bus: {
+ id: bus.id,
+ name: bus.name,
+ seatCapacity: bus.seat_capacity,
+ licensePlate: bus.license_plate,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Delete fleet bus
// ============================================
-app.delete('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busId } = req.params;
- if (!busId || !Number.isFinite(Number(busId))) {
- return res.status(400).json({ error: 'invalid_bus_id' });
- }
+app.delete(
+ "/admin/fleet/buses/:busId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busId } = req.params;
+ if (!busId || !Number.isFinite(Number(busId))) {
+ return res.status(400).json({ error: "invalid_bus_id" });
+ }
- if (DEV_MOCK) {
- const idx = _mock.fleetBuses.findIndex(b => Number(b.id) === Number(busId));
- if (idx === -1) return res.status(404).json({ error: 'bus_not_found' });
- const deleted = _mock.fleetBuses.splice(idx, 1)[0];
- return res.json({ success: true, bus: deleted });
- }
+ if (DEV_MOCK) {
+ const idx = _mock.fleetBuses.findIndex(
+ (b) => Number(b.id) === Number(busId),
+ );
+ if (idx === -1) return res.status(404).json({ error: "bus_not_found" });
+ const deleted = _mock.fleetBuses.splice(idx, 1)[0];
+ return res.json({ success: true, bus: deleted });
+ }
- const result = await db.query(`DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`, [busId]);
- if (result.rows.length === 0) return res.status(404).json({ error: 'bus_not_found' });
- res.json({ success: true, bus: result.rows[0] });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ const result = await db.query(
+ `DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`,
+ [busId],
+ );
+ if (result.rows.length === 0)
+ return res.status(404).json({ error: "bus_not_found" });
+ res.json({ success: true, bus: result.rows[0] });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Link stop to bus line
// ============================================
-app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busLineId } = req.params;
- if (!busLineId || !Number.isFinite(Number(busLineId))) {
- return res.status(400).json({ error: 'invalid_bus_line_id' });
- }
+app.get(
+ "/admin/bus-lines/:busLineId/stops",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busLineId } = req.params;
+ if (!busLineId || !Number.isFinite(Number(busLineId))) {
+ return res.status(400).json({ error: "invalid_bus_line_id" });
+ }
- if (DEV_MOCK) {
- const stops = _mock.routeStops
- .filter(rs => String(rs.busLineId) === String(busLineId))
- .sort((a, b) => a.stopOrder - b.stopOrder)
- .map(rs => ({
- id: rs.id,
- busLineId: rs.busLineId,
- stopId: rs.stopId,
- stopOrder: rs.stopOrder,
- estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
- name: rs.name,
- latitude: rs.latitude,
- longitude: rs.longitude,
- }));
- return res.json({ routeStops: stops });
- }
+ if (DEV_MOCK) {
+ const stops = _mock.routeStops
+ .filter((rs) => String(rs.busLineId) === String(busLineId))
+ .sort((a, b) => a.stopOrder - b.stopOrder)
+ .map((rs) => ({
+ id: rs.id,
+ busLineId: rs.busLineId,
+ stopId: rs.stopId,
+ stopOrder: rs.stopOrder,
+ estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
+ name: rs.name,
+ latitude: rs.latitude,
+ longitude: rs.longitude,
+ }));
+ return res.json({ routeStops: stops });
+ }
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
rs.id,
rs.bus_line_id,
@@ -1379,54 +1779,70 @@ app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, as
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
- `, [busLineId]);
+ `,
+ [busLineId],
+ );
- res.json({ routeStops: result.rows.map(row => ({
- id: row.id,
- busLineId: row.bus_line_id,
- stopId: row.stop_id,
- stopOrder: row.stop_order,
- estimatedArrivalMinutes: row.estimated_arrival_minutes,
- name: row.stop_name,
- latitude: parseFloat(row.latitude),
- longitude: parseFloat(row.longitude),
- })) });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busLineId } = req.params;
- const { stopId, stopOrder, estimatedArrivalMinutes } = req.body;
-
- if (!stopId || stopOrder === undefined) {
- return res.status(400).json({ error: 'missing_required_fields' });
+ res.json({
+ routeStops: result.rows.map((row) => ({
+ id: row.id,
+ busLineId: row.bus_line_id,
+ stopId: row.stop_id,
+ stopOrder: row.stop_order,
+ estimatedArrivalMinutes: row.estimated_arrival_minutes,
+ name: row.stop_name,
+ latitude: parseFloat(row.latitude),
+ longitude: parseFloat(row.longitude),
+ })),
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
+ },
+);
- if (DEV_MOCK) {
- const stop = _mock.stops.find(s => String(s.id) === String(stopId));
- const routeStop = {
- id: _mock.nextRouteStopId++,
- busLineId: Number(busLineId),
- stopId: Number(stopId),
- stopOrder: Number(stopOrder),
- estimatedArrivalMinutes: estimatedArrivalMinutes ?? null,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- name: stop ? stop.name : `stop-${stopId}`,
- latitude: stop ? stop.latitude : 0,
- longitude: stop ? stop.longitude : 0,
- };
- // remove existing with same busLineId & stopOrder
- _mock.routeStops = _mock.routeStops.filter(rs => !(rs.busLineId === routeStop.busLineId && rs.stopOrder === routeStop.stopOrder));
- _mock.routeStops.push(routeStop);
- return res.status(201).json({ routeStop });
- }
+app.post(
+ "/admin/bus-lines/:busLineId/stops",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busLineId } = req.params;
+ const { stopId, stopOrder, estimatedArrivalMinutes } = req.body;
- const result = await db.query(`
+ if (!stopId || stopOrder === undefined) {
+ return res.status(400).json({ error: "missing_required_fields" });
+ }
+
+ if (DEV_MOCK) {
+ const stop = _mock.stops.find((s) => String(s.id) === String(stopId));
+ const routeStop = {
+ id: _mock.nextRouteStopId++,
+ busLineId: Number(busLineId),
+ stopId: Number(stopId),
+ stopOrder: Number(stopOrder),
+ estimatedArrivalMinutes: estimatedArrivalMinutes ?? null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ name: stop ? stop.name : `stop-${stopId}`,
+ latitude: stop ? stop.latitude : 0,
+ longitude: stop ? stop.longitude : 0,
+ };
+ // remove existing with same busLineId & stopOrder
+ _mock.routeStops = _mock.routeStops.filter(
+ (rs) =>
+ !(
+ rs.busLineId === routeStop.busLineId &&
+ rs.stopOrder === routeStop.stopOrder
+ ),
+ );
+ _mock.routeStops.push(routeStop);
+ return res.status(201).json({ routeStop });
+ }
+
+ const result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.route_stops
)
@@ -1438,110 +1854,132 @@ app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, a
estimated_arrival_minutes = EXCLUDED.estimated_arrival_minutes,
updated_at = NOW()
RETURNING id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes, created_at, updated_at
- `, [busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null]);
+ `,
+ [busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null],
+ );
- const link = result.rows[0];
- res.status(201).json({
- routeStop: {
- id: link.id,
- busLineId: link.bus_line_id,
- stopId: link.stop_id,
- stopOrder: link.stop_order,
- estimatedArrivalMinutes: link.estimated_arrival_minutes,
- createdAt: link.created_at,
- updatedAt: link.updated_at,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddleware, async (req, res) => {
- const client = await db.pool.connect();
- try {
- const { busLineId } = req.params;
- const { routeStopIds } = req.body || {};
-
- if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) {
- return res.status(400).json({ error: 'routeStopIds array required' });
+ const link = result.rows[0];
+ res.status(201).json({
+ routeStop: {
+ id: link.id,
+ busLineId: link.bus_line_id,
+ stopId: link.stop_id,
+ stopOrder: link.stop_order,
+ estimatedArrivalMinutes: link.estimated_arrival_minutes,
+ createdAt: link.created_at,
+ updatedAt: link.updated_at,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
+ },
+);
- const normalizedIds = routeStopIds.map(id => Number(id)).filter(id => Number.isFinite(id));
- if (normalizedIds.length !== routeStopIds.length) {
- return res.status(400).json({ error: 'routeStopIds must contain only numeric ids' });
- }
+app.post(
+ "/admin/bus-lines/:busLineId/stops/reorder",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ const client = await db.pool.connect();
+ try {
+ const { busLineId } = req.params;
+ const { routeStopIds } = req.body || {};
- if (DEV_MOCK) {
- const busLineStops = _mock.routeStops
- .filter(rs => String(rs.busLineId) === String(busLineId))
- .sort((a, b) => a.stopOrder - b.stopOrder);
+ if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) {
+ return res.status(400).json({ error: "routeStopIds array required" });
+ }
+
+ const normalizedIds = routeStopIds
+ .map((id) => Number(id))
+ .filter((id) => Number.isFinite(id));
+ if (normalizedIds.length !== routeStopIds.length) {
+ return res
+ .status(400)
+ .json({ error: "routeStopIds must contain only numeric ids" });
+ }
+
+ if (DEV_MOCK) {
+ const busLineStops = _mock.routeStops
+ .filter((rs) => String(rs.busLineId) === String(busLineId))
+ .sort((a, b) => a.stopOrder - b.stopOrder);
+
+ const idSet = new Set(busLineStops.map((rs) => Number(rs.id)));
+ const sameCount = busLineStops.length === normalizedIds.length;
+ const sameIds = sameCount && normalizedIds.every((id) => idSet.has(id));
+ if (!sameIds) {
+ return res
+ .status(400)
+ .json({ error: "routeStopIds must match current bus line stops" });
+ }
+
+ const updated = normalizedIds.map((routeStopId, index) => {
+ const stop = _mock.routeStops.find(
+ (rs) =>
+ Number(rs.id) === routeStopId &&
+ String(rs.busLineId) === String(busLineId),
+ );
+ if (!stop) return null;
+ stop.stopOrder = index + 1;
+ stop.updatedAt = new Date().toISOString();
+ return stop;
+ });
+
+ if (updated.some((item) => !item)) {
+ return res
+ .status(400)
+ .json({ error: "routeStopIds must match current bus line stops" });
+ }
+
+ _mock.routeStops = _mock.routeStops.map((routeStop) => {
+ const reordered = updated.find((item) => item.id === routeStop.id);
+ return reordered || routeStop;
+ });
+
+ return res.json({
+ routeStops: updated,
+ });
+ }
+
+ const current = await client.query(
+ `SELECT id FROM transit.route_stops WHERE bus_line_id = $1 ORDER BY stop_order ASC`,
+ [busLineId],
+ );
+
+ const currentIds = current.rows.map((row) => Number(row.id));
+ const sameCount = currentIds.length === normalizedIds.length;
+ const sameIds =
+ sameCount && normalizedIds.every((id) => currentIds.includes(id));
- const idSet = new Set(busLineStops.map(rs => Number(rs.id)));
- const sameCount = busLineStops.length === normalizedIds.length;
- const sameIds = sameCount && normalizedIds.every(id => idSet.has(id));
if (!sameIds) {
- return res.status(400).json({ error: 'routeStopIds must match current bus line stops' });
+ return res
+ .status(400)
+ .json({ error: "routeStopIds must match current bus line stops" });
}
- const updated = normalizedIds.map((routeStopId, index) => {
- const stop = _mock.routeStops.find(rs => Number(rs.id) === routeStopId && String(rs.busLineId) === String(busLineId));
- if (!stop) return null;
- stop.stopOrder = index + 1;
- stop.updatedAt = new Date().toISOString();
- return stop;
- });
+ await client.query("BEGIN");
- if (updated.some(item => !item)) {
- return res.status(400).json({ error: 'routeStopIds must match current bus line stops' });
+ for (let index = 0; index < normalizedIds.length; index += 1) {
+ await client.query(
+ `UPDATE transit.route_stops
+ SET stop_order = $1, updated_at = NOW()
+ WHERE id = $2 AND bus_line_id = $3`,
+ [-(index + 1), normalizedIds[index], busLineId],
+ );
}
- _mock.routeStops = _mock.routeStops.map(routeStop => {
- const reordered = updated.find(item => item.id === routeStop.id);
- return reordered || routeStop;
- });
-
- return res.json({
- routeStops: updated,
- });
- }
-
- const current = await client.query(
- `SELECT id FROM transit.route_stops WHERE bus_line_id = $1 ORDER BY stop_order ASC`,
- [busLineId]
- );
-
- const currentIds = current.rows.map(row => Number(row.id));
- const sameCount = currentIds.length === normalizedIds.length;
- const sameIds = sameCount && normalizedIds.every(id => currentIds.includes(id));
-
- if (!sameIds) {
- return res.status(400).json({ error: 'routeStopIds must match current bus line stops' });
- }
-
- await client.query('BEGIN');
-
- for (let index = 0; index < normalizedIds.length; index += 1) {
- await client.query(
- `UPDATE transit.route_stops
+ for (let index = 0; index < normalizedIds.length; index += 1) {
+ await client.query(
+ `UPDATE transit.route_stops
SET stop_order = $1, updated_at = NOW()
WHERE id = $2 AND bus_line_id = $3`,
- [-(index + 1), normalizedIds[index], busLineId]
- );
- }
+ [index + 1, normalizedIds[index], busLineId],
+ );
+ }
- for (let index = 0; index < normalizedIds.length; index += 1) {
- await client.query(
- `UPDATE transit.route_stops
- SET stop_order = $1, updated_at = NOW()
- WHERE id = $2 AND bus_line_id = $3`,
- [index + 1, normalizedIds[index], busLineId]
- );
- }
-
- const result = await client.query(
- `SELECT
+ const result = await client.query(
+ `SELECT
rs.id,
rs.stop_order,
s.name as stop_name,
@@ -1552,238 +1990,274 @@ app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddl
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC`,
- [busLineId]
- );
+ [busLineId],
+ );
- await client.query('COMMIT');
+ await client.query("COMMIT");
- res.json({
- routeStops: result.rows.map(row => ({
- id: row.id,
- order: row.stop_order,
- name: row.stop_name,
- latitude: parseFloat(row.latitude),
- longitude: parseFloat(row.longitude),
- estimatedArrivalMinutes: row.estimated_arrival_minutes,
- })),
- });
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {});
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- } finally {
- client.release();
- }
-});
+ res.json({
+ routeStops: result.rows.map((row) => ({
+ id: row.id,
+ order: row.stop_order,
+ name: row.stop_name,
+ latitude: parseFloat(row.latitude),
+ longitude: parseFloat(row.longitude),
+ estimatedArrivalMinutes: row.estimated_arrival_minutes,
+ })),
+ });
+ } catch (err) {
+ await client.query("ROLLBACK").catch(() => {});
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ } finally {
+ client.release();
+ }
+ },
+);
// ============================================
// ADMIN - Delete a stop
// ============================================
-app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { stopId } = req.params;
+app.delete(
+ "/admin/stops/:stopId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { stopId } = req.params;
- if (!stopId || !Number.isFinite(Number(stopId))) {
- return res.status(400).json({ error: 'invalid_stop_id' });
- }
-
- if (DEV_MOCK) {
- const index = _mock.stops.findIndex(s => Number(s.id) === Number(stopId));
- if (index === -1) {
- return res.status(404).json({ error: 'stop_not_found' });
+ if (!stopId || !Number.isFinite(Number(stopId))) {
+ return res.status(400).json({ error: "invalid_stop_id" });
}
- const deletedStop = _mock.stops.splice(index, 1)[0];
- return res.json({ success: true, stop: deletedStop });
- }
- const result = await db.query(
- `DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`,
- [stopId]
- );
+ if (DEV_MOCK) {
+ const index = _mock.stops.findIndex(
+ (s) => Number(s.id) === Number(stopId),
+ );
+ if (index === -1) {
+ return res.status(404).json({ error: "stop_not_found" });
+ }
+ const deletedStop = _mock.stops.splice(index, 1)[0];
+ return res.json({ success: true, stop: deletedStop });
+ }
- if (result.rows.length === 0) {
- return res.status(404).json({ error: 'stop_not_found' });
- }
+ const result = await db.query(
+ `DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`,
+ [stopId],
+ );
- res.json({ success: true, stop: result.rows[0] });
- } catch (err) {
- console.error(err);
- if (err.code === '23503') {
- return res.status(409).json({ error: 'foreign_key_violation', message: 'This stop is referenced by route_stops' });
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: "stop_not_found" });
+ }
+
+ res.json({ success: true, stop: result.rows[0] });
+ } catch (err) {
+ console.error(err);
+ if (err.code === "23503") {
+ return res.status(409).json({
+ error: "foreign_key_violation",
+ message: "This stop is referenced by route_stops",
+ });
+ }
+ res.status(500).json({ error: "internal_error", details: err.message });
}
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ },
+);
// ============================================
// ADMIN - Update a stop
// ============================================
-app.patch('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { stopId } = req.params;
- const { name, latitude, longitude } = req.body;
+app.patch(
+ "/admin/stops/:stopId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { stopId } = req.params;
+ const { name, latitude, longitude } = req.body;
- if (!stopId || !Number.isFinite(Number(stopId))) {
- return res.status(400).json({ error: 'invalid_stop_id' });
- }
+ if (!stopId || !Number.isFinite(Number(stopId))) {
+ return res.status(400).json({ error: "invalid_stop_id" });
+ }
- if (DEV_MOCK) {
- const stop = _mock.stops.find(s => Number(s.id) === Number(stopId));
- if (!stop) return res.status(404).json({ error: 'stop_not_found' });
- if (name !== undefined) stop.name = name;
- if (latitude !== undefined) stop.latitude = parseFloat(latitude);
- if (longitude !== undefined) stop.longitude = parseFloat(longitude);
- stop.updatedAt = new Date().toISOString();
- return res.json({ stop });
- }
+ if (DEV_MOCK) {
+ const stop = _mock.stops.find((s) => Number(s.id) === Number(stopId));
+ if (!stop) return res.status(404).json({ error: "stop_not_found" });
+ if (name !== undefined) stop.name = name;
+ if (latitude !== undefined) stop.latitude = parseFloat(latitude);
+ if (longitude !== undefined) stop.longitude = parseFloat(longitude);
+ stop.updatedAt = new Date().toISOString();
+ return res.json({ stop });
+ }
- const result = await db.query(
- `UPDATE transit.stops
+ const result = await db.query(
+ `UPDATE transit.stops
SET name = COALESCE($1, name),
latitude = COALESCE($2, latitude),
longitude = COALESCE($3, longitude),
updated_at = NOW()
WHERE id = $4
- RETURNING id, name, latitude, longitude, created_at, updated_at`
- , [name ?? null, latitude ?? null, longitude ?? null, stopId]);
+ RETURNING id, name, latitude, longitude, created_at, updated_at`,
+ [name ?? null, latitude ?? null, longitude ?? null, stopId],
+ );
- if (!result.rows[0]) return res.status(404).json({ error: 'stop_not_found' });
+ if (!result.rows[0])
+ return res.status(404).json({ error: "stop_not_found" });
- const stop = result.rows[0];
- res.json({ stop: {
- id: stop.id,
- name: stop.name,
- latitude: parseFloat(stop.latitude),
- longitude: parseFloat(stop.longitude),
- createdAt: stop.created_at,
- updatedAt: stop.updated_at,
- }});
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ const stop = result.rows[0];
+ res.json({
+ stop: {
+ id: stop.id,
+ name: stop.name,
+ latitude: parseFloat(stop.latitude),
+ longitude: parseFloat(stop.longitude),
+ createdAt: stop.created_at,
+ updatedAt: stop.updated_at,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Delete a bus line
// ============================================
-app.delete('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => {
- const client = await db.pool.connect();
- try {
- const { busLineId } = req.params;
+app.delete(
+ "/admin/bus-lines/:busLineId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ const client = await db.pool.connect();
+ try {
+ const { busLineId } = req.params;
- if (!busLineId || !Number.isFinite(Number(busLineId))) {
- return res.status(400).json({ error: 'invalid_bus_line_id' });
- }
-
- if (DEV_MOCK) {
- const lineIndex = _mock.busLines.findIndex(bl => Number(bl.id) === Number(busLineId));
- if (lineIndex === -1) {
- return res.status(404).json({ error: 'bus_line_not_found' });
+ if (!busLineId || !Number.isFinite(Number(busLineId))) {
+ return res.status(400).json({ error: "invalid_bus_line_id" });
}
- const deletedLine = _mock.busLines.splice(lineIndex, 1)[0];
- // Also delete route_stops for this line
- _mock.routeStops = _mock.routeStops.filter(rs => String(rs.busLineId) !== String(busLineId));
- return res.json({ success: true, busLine: deletedLine });
+
+ if (DEV_MOCK) {
+ const lineIndex = _mock.busLines.findIndex(
+ (bl) => Number(bl.id) === Number(busLineId),
+ );
+ if (lineIndex === -1) {
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
+ const deletedLine = _mock.busLines.splice(lineIndex, 1)[0];
+ // Also delete route_stops for this line
+ _mock.routeStops = _mock.routeStops.filter(
+ (rs) => String(rs.busLineId) !== String(busLineId),
+ );
+ return res.json({ success: true, busLine: deletedLine });
+ }
+
+ await client.query("BEGIN");
+
+ // Delete associated route_stops first
+ await client.query(
+ `DELETE FROM transit.route_stops WHERE bus_line_id = $1`,
+ [busLineId],
+ );
+
+ // Delete the bus line
+ const result = await client.query(
+ `DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`,
+ [busLineId],
+ );
+
+ if (result.rows.length === 0) {
+ await client.query("ROLLBACK");
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
+
+ await client.query("COMMIT");
+ res.json({ success: true, busLine: result.rows[0] });
+ } catch (err) {
+ await client.query("ROLLBACK").catch(() => {});
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ } finally {
+ client.release();
}
-
- await client.query('BEGIN');
-
- // Delete associated route_stops first
- await client.query(
- `DELETE FROM transit.route_stops WHERE bus_line_id = $1`,
- [busLineId]
- );
-
- // Delete the bus line
- const result = await client.query(
- `DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`,
- [busLineId]
- );
-
- if (result.rows.length === 0) {
- await client.query('ROLLBACK');
- return res.status(404).json({ error: 'bus_line_not_found' });
- }
-
- await client.query('COMMIT');
- res.json({ success: true, busLine: result.rows[0] });
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {});
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- } finally {
- client.release();
- }
-});
+ },
+);
// ============================================
// ADMIN - Delete a route_stop from bus line
// ============================================
-app.delete('/admin/bus-lines/:busLineId/stops/:routeStopId', authMiddleware, adminMiddleware, async (req, res) => {
- const client = await db.pool.connect();
- try {
- const { busLineId, routeStopId } = req.params;
+app.delete(
+ "/admin/bus-lines/:busLineId/stops/:routeStopId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ const client = await db.pool.connect();
+ try {
+ const { busLineId, routeStopId } = req.params;
- if (!busLineId || !Number.isFinite(Number(busLineId))) {
- return res.status(400).json({ error: 'invalid_bus_line_id' });
- }
-
- if (!routeStopId || !Number.isFinite(Number(routeStopId))) {
- return res.status(400).json({ error: 'invalid_route_stop_id' });
- }
-
- if (DEV_MOCK) {
- const index = _mock.routeStops.findIndex(
- rs => Number(rs.id) === Number(routeStopId) && String(rs.busLineId) === String(busLineId)
- );
- if (index === -1) {
- return res.status(404).json({ error: 'route_stop_not_found' });
+ if (!busLineId || !Number.isFinite(Number(busLineId))) {
+ return res.status(400).json({ error: "invalid_bus_line_id" });
}
- const deletedRouteStop = _mock.routeStops.splice(index, 1)[0];
- return res.json({ success: true, routeStop: deletedRouteStop });
- }
- await client.query('BEGIN');
+ if (!routeStopId || !Number.isFinite(Number(routeStopId))) {
+ return res.status(400).json({ error: "invalid_route_stop_id" });
+ }
- // Find and delete the route_stop
- const result = await client.query(
- `DELETE FROM transit.route_stops
+ if (DEV_MOCK) {
+ const index = _mock.routeStops.findIndex(
+ (rs) =>
+ Number(rs.id) === Number(routeStopId) &&
+ String(rs.busLineId) === String(busLineId),
+ );
+ if (index === -1) {
+ return res.status(404).json({ error: "route_stop_not_found" });
+ }
+ const deletedRouteStop = _mock.routeStops.splice(index, 1)[0];
+ return res.json({ success: true, routeStop: deletedRouteStop });
+ }
+
+ await client.query("BEGIN");
+
+ // Find and delete the route_stop
+ const result = await client.query(
+ `DELETE FROM transit.route_stops
WHERE id = $1 AND bus_line_id = $2
RETURNING id, stop_order`,
- [routeStopId, busLineId]
- );
+ [routeStopId, busLineId],
+ );
- if (result.rows.length === 0) {
- await client.query('ROLLBACK');
- return res.status(404).json({ error: 'route_stop_not_found' });
- }
+ if (result.rows.length === 0) {
+ await client.query("ROLLBACK");
+ return res.status(404).json({ error: "route_stop_not_found" });
+ }
- const deletedOrder = result.rows[0].stop_order;
+ const deletedOrder = result.rows[0].stop_order;
- // Re-order remaining stops
- await client.query(
- `UPDATE transit.route_stops
+ // Re-order remaining stops
+ await client.query(
+ `UPDATE transit.route_stops
SET stop_order = stop_order - 1, updated_at = NOW()
WHERE bus_line_id = $1 AND stop_order > $2`,
- [busLineId, deletedOrder]
- );
+ [busLineId, deletedOrder],
+ );
- await client.query('COMMIT');
- res.json({ success: true, routeStop: result.rows[0] });
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {});
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- } finally {
- client.release();
- }
-});
+ await client.query("COMMIT");
+ res.json({ success: true, routeStop: result.rows[0] });
+ } catch (err) {
+ await client.query("ROLLBACK").catch(() => {});
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ } finally {
+ client.release();
+ }
+ },
+);
// ============================================
// NAVIGATION - Save calculated route
// ============================================
-app.post('/navigation/route', authMiddleware, async (req, res) => {
+app.post("/navigation/route", authMiddleware, async (req, res) => {
try {
const {
routeId,
@@ -1796,11 +2270,20 @@ app.post('/navigation/route', authMiddleware, async (req, res) => {
geojson,
} = req.body;
- if (!routeId || !startLat || !startLon || !endLat || !endLon || !totalDistance || !totalDuration) {
- return res.status(400).json({ error: 'missing_required_fields' });
+ if (
+ !routeId ||
+ !startLat ||
+ !startLon ||
+ !endLat ||
+ !endLon ||
+ !totalDistance ||
+ !totalDuration
+ ) {
+ return res.status(400).json({ error: "missing_required_fields" });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
INSERT INTO navigation_routes
(route_id, start_lat, start_lon, end_lat, end_lon,
total_distance_meters, total_duration_seconds, polyline_geojson)
@@ -1814,67 +2297,80 @@ app.post('/navigation/route', authMiddleware, async (req, res) => {
total_duration_seconds = $7,
polyline_geojson = $8
RETURNING *
- `, [
- routeId,
- startLat,
- startLon,
- endLat,
- endLon,
- totalDistance,
- totalDuration,
- JSON.stringify(geojson),
- ]);
+ `,
+ [
+ routeId,
+ startLat,
+ startLon,
+ endLat,
+ endLon,
+ totalDistance,
+ totalDuration,
+ JSON.stringify(geojson),
+ ],
+ );
res.json({ route: result.rows[0] });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// GPS TRACKING - Record current position
// ============================================
-app.post('/gps/track', authMiddleware, async (req, res) => {
+app.post("/gps/track", authMiddleware, async (req, res) => {
try {
const { routeId, latitude, longitude, speed, heading, accuracy } = req.body;
if (!routeId || !latitude || !longitude) {
- return res.status(400).json({ error: 'missing_required_fields' });
+ return res.status(400).json({ error: "missing_required_fields" });
}
- await db.query(`
+ await db.query(
+ `
INSERT INTO gps_tracking
(route_id, latitude, longitude, speed_kmh, heading, accuracy, recorded_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
- `, [routeId, latitude, longitude, speed || null, heading || null, accuracy || null]);
+ `,
+ [
+ routeId,
+ latitude,
+ longitude,
+ speed || null,
+ heading || null,
+ accuracy || null,
+ ],
+ );
res.json({ ok: true });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// GPS TRACKING - Get route history
// ============================================
-app.get('/gps/route/:routeId', authMiddleware, async (req, res) => {
+app.get("/gps/route/:routeId", authMiddleware, async (req, res) => {
try {
const { routeId } = req.params;
const userId = req.user.userId;
// Verify user owns this route
const routeCheck = await db.query(
- 'SELECT id FROM routes WHERE id = $1 AND user_id = $2',
- [routeId, userId]
+ "SELECT id FROM routes WHERE id = $1 AND user_id = $2",
+ [routeId, userId],
);
if (routeCheck.rows.length === 0) {
- return res.status(403).json({ error: 'unauthorized' });
+ return res.status(403).json({ error: "unauthorized" });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
id,
latitude,
@@ -1886,9 +2382,11 @@ app.get('/gps/route/:routeId', authMiddleware, async (req, res) => {
FROM gps_tracking
WHERE route_id = $1
ORDER BY recorded_at ASC
- `, [routeId]);
+ `,
+ [routeId],
+ );
- const positions = result.rows.map(row => ({
+ const positions = result.rows.map((row) => ({
id: row.id,
latitude: parseFloat(row.latitude),
longitude: parseFloat(row.longitude),
@@ -1901,172 +2399,283 @@ app.get('/gps/route/:routeId', authMiddleware, async (req, res) => {
res.json({ positions });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ROUTES - Update route status
// ============================================
-app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
+app.patch("/routes/:routeId/status", authMiddleware, async (req, res) => {
try {
const { routeId } = req.params;
const { status } = req.body;
const userId = req.user.userId;
- const validStatuses = ['scheduled', 'in_progress', 'completed', 'cancelled'];
+ const validStatuses = [
+ "scheduled",
+ "in_progress",
+ "completed",
+ "cancelled",
+ ];
if (!validStatuses.includes(status)) {
- return res.status(400).json({ error: 'invalid_status' });
+ return res.status(400).json({ error: "invalid_status" });
}
- const result = await db.query(`
+ const result = await db.query(
+ `
UPDATE transit.routes
SET status = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING *
- `, [status, routeId, userId]);
+ `,
+ [status, routeId, userId],
+ );
if (result.rows.length === 0) {
- return res.status(404).json({ error: 'route_not_found' });
+ return res.status(404).json({ error: "route_not_found" });
}
res.json({ route: result.rows[0] });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// DRIVER - Update schedule status
// ============================================
-app.patch('/driver/schedules/:scheduleId/status', authMiddleware, async (req, res) => {
- try {
- const { scheduleId } = req.params;
- const { status } = req.body;
-
- const validStatuses = ['planned', 'in_progress', 'completed', 'cancelled', 'active'];
- if (!validStatuses.includes(status)) {
- return res.status(400).json({ error: 'invalid_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;
- }
+app.patch(
+ "/driver/weekly-schedules/:scheduleId/status",
+ authMiddleware,
+ async (req, res) => {
+ try {
+ const scheduleId = Number(req.params.scheduleId);
+ const { status } = req.body;
+ const validStatuses = [
+ "planned",
+ "in_progress",
+ "completed",
+ "cancelled",
+ "active",
+ ];
+ if (!Number.isInteger(scheduleId) || !validStatuses.includes(status)) {
+ return res.status(400).json({ error: "invalid_status" });
}
- 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()
- );
+ const normalizedStatus = status === "active" ? "in_progress" : status;
+ const result = await db.query(
+ `
+ UPDATE transit.schedules s
+ SET status = $1, updated_at = NOW()
+ WHERE s.id = $2
+ AND EXISTS (
+ SELECT 1 FROM transit.shifts sh
+ WHERE sh.schedule_id = s.id AND sh.driver_id = $3
+ )
+ RETURNING s.id, s.status
+ `,
+ [normalizedStatus, scheduleId, req.user.userId],
+ );
- let occurrenceEnd = null;
- if (Number.isFinite(templateEnd.getTime()) && Number.isFinite(templateStart.getTime())) {
- const duration = templateEnd.getTime() - templateStart.getTime();
- occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
+ if (result.rows.length === 0)
+ return res.status(404).json({ error: "schedule_not_found" });
+ res.json({
+ schedule: {
+ id: Number(result.rows[0].id),
+ status: result.rows[0].status,
+ },
+ });
+ } catch (err) {
+ console.error("PATCH /driver/weekly-schedules/:id/status error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.patch(
+ "/driver/schedules/:scheduleId/status",
+ authMiddleware,
+ async (req, res) => {
+ try {
+ const { scheduleId } = req.params;
+ const { status } = req.body;
+
+ const validStatuses = [
+ "planned",
+ "in_progress",
+ "completed",
+ "cancelled",
+ "active",
+ ];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ error: "invalid_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;
+ }
}
- // 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 (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(),
+ );
- if (existingInstance.rows.length > 0) {
- const upd = await db.query(`
+ 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] });
- }
+ `,
+ [normalizedStatus, existingInstance.rows[0].id],
+ );
+ return res.json({ schedule: upd.rows[0] });
+ }
- // Insert a new instance derived from template
- const hasTemplateIdCol = await db.query(`
+ // 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(`
+ 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(`
+ `,
+ [
+ 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]);
+ `,
+ [
+ scheduleRow.bus_line_id,
+ scheduleRow.bus_id,
+ scheduleRow.driver_id,
+ occurrenceStart.toISOString(),
+ occurrenceEnd ? occurrenceEnd.toISOString() : null,
+ normalizedStatus,
+ ],
+ );
+ }
+
+ return res.json({ schedule: insertResult.rows[0] });
}
-
- 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(`
+ // 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()
WHERE id = $2
RETURNING *
- `, [normalizedStatus, scheduleId]);
+ `,
+ [normalizedStatus, scheduleId],
+ );
- if (result.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
+
+ res.json({ schedule: result.rows[0] });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
-
- res.json({ schedule: result.rows[0] });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ },
+);
// ============================================
// ADMIN - Get drivers for scheduling
// ============================================
-app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
+app.get("/admin/drivers", authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
- const drivers = _mock.drivers.slice().filter(d => d.status === 'active');
- return res.json({
- drivers: drivers.map(d => ({
+ const drivers = _mock.drivers
+ .slice()
+ .filter((d) => d.status === "active");
+ return res.json({
+ drivers: drivers.map((d) => ({
...d,
- available: !checkDriverConflict(d.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || [])
- }))
+ available: !checkDriverConflict(
+ d.id,
+ new Date().toISOString(),
+ new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+ [],
+ _mock.schedules || [],
+ ),
+ })),
});
}
@@ -2078,23 +2687,35 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
`);
// Get all schedules to check for conflicts
- const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`);
- const schedules = schedulesResult.rows.map(row => ({
+ const schedulesResult = await db.query(
+ `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
+ );
+ const schedules = schedulesResult.rows.map((row) => ({
...row,
weekdays: parseWeekdaysValue(row.weekdays),
}));
res.json({
- drivers: result.rows.map(row => {
+ drivers: result.rows.map((row) => {
const driverId = row.id;
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
- const isAvailable = !checkDriverConflict(driverId, now.toISOString(), tomorrow.toISOString(), [], schedules);
-
+ const isAvailable = !checkDriverConflict(
+ driverId,
+ now.toISOString(),
+ tomorrow.toISOString(),
+ [],
+ schedules,
+ );
+
return {
id: row.id,
- name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(),
+ name: (
+ String(row.firstname || "") +
+ " " +
+ String(row.lastname || "")
+ ).trim(),
email: row.email,
phone: row.phone,
job: row.job,
@@ -2106,65 +2727,389 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ADMIN - Manage schedules (timetable)
// ============================================
-app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const templatesOnly = String(req.query.templates || req.query.includeTemplates || '').toLowerCase() === 'true' || String(req.query.templates || req.query.includeTemplates || '') === '1';
+app.get(
+ "/admin/shift-drivers",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const result = await db.query(`
+ SELECT id, usercode
+ FROM workers.users
+ ORDER BY usercode ASC
+ `);
+ res.json({
+ drivers: result.rows.map((row) => ({
+ id: Number(row.id),
+ name: String(row.usercode),
+ })),
+ });
+ } catch (err) {
+ console.error("GET /admin/shift-drivers error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
- if (DEV_MOCK) {
- const routeStopsByLine = new Map();
- for (const routeStop of _mock.routeStops || []) {
- const busLineId = Number(routeStop.busLineId);
- if (!routeStopsByLine.has(busLineId)) {
- routeStopsByLine.set(busLineId, []);
- }
- routeStopsByLine.get(busLineId).push({
- stop_id: routeStop.stopId,
- stop_order: routeStop.stopOrder,
+app.get(
+ "/admin/weekly-schedules",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const result = await db.query(`
+ SELECT s.id, s.bus_line_id, s.start_time, s.end_time, s.status, s.day_of_week,
+ bl.line_number, bl.start_stop, bl.end_stop,
+ sh.id AS shift_id, sh.driver_id, u.usercode AS driver_name
+ FROM transit.schedules s
+ JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
+ LEFT JOIN transit.shifts sh ON sh.schedule_id = s.id
+ LEFT JOIN workers.users u ON u.id = sh.driver_id
+ ORDER BY s.day_of_week ASC, s.start_time ASC, s.id ASC
+ `);
+ res.json({
+ schedules: result.rows.map((row) => ({
+ id: Number(row.id),
+ busLineId: Number(row.bus_line_id),
+ lineNumber: row.line_number,
+ startStop: row.start_stop,
+ endStop: row.end_stop,
+ startTime: row.start_time,
+ endTime: row.end_time,
+ status: row.status,
+ dayOfWeek: Number(row.day_of_week),
+ shiftId: row.shift_id ? Number(row.shift_id) : null,
+ driverId: row.driver_id ? Number(row.driver_id) : null,
+ driverName: row.driver_name || null,
+ })),
+ });
+ } catch (err) {
+ console.error("GET /admin/weekly-schedules error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.post(
+ "/admin/weekly-schedules",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const busLineId = Number(req.body?.busLineId);
+ const dayOfWeek = Number(req.body?.dayOfWeek);
+ const startTime = String(req.body?.startTime || "");
+ const endTime = String(req.body?.endTime || "");
+ const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
+
+ if (
+ !Number.isInteger(busLineId) ||
+ !Number.isInteger(dayOfWeek) ||
+ dayOfWeek < 0 ||
+ dayOfWeek > 6 ||
+ !startTime ||
+ !endTime
+ ) {
+ return res.status(400).json({
+ error: "busLineId, dayOfWeek, startTime and endTime are required",
});
}
- const rawSchedules = (_mock.schedules || []).map(s => {
- const busLine = _mock.busLines.find(bl => bl.id === s.busLineId);
- const bus = _mock.fleetBuses.find(b => b.id === s.busId);
- return {
- ...s,
- busLineId: s.busLineId,
- busId: s.busId,
- startTime: s.startTime,
- endTime: s.endTime,
- weekdays: s.weekdays || [],
- status: s.status,
- lineNumber: busLine?.lineNumber || 0,
- busName: bus?.name || 'Unknown Bus',
- driverId: s.driverId || null,
- estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
- };
- });
+ const created = await db.query(
+ `
+ INSERT INTO transit.schedules (bus_line_id, start_time, end_time, status, day_of_week)
+ VALUES ($1, $2::time, $3::time, 'planned', $4)
+ RETURNING id, bus_line_id, start_time, end_time, status, day_of_week
+ `,
+ [busLineId, startTime, endTime, dayOfWeek],
+ );
- const schedules = (templatesOnly ? rawSchedules : expandRecurringScheduleRows(rawSchedules))
- .filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine))
- .map(s => ({
- ...s,
- departureTimes: calculateDepartureTimes(s.startTime, s.busLineId, _mock),
- }))
- .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
-
- return res.json({ schedules });
+ const schedule = created.rows[0];
+ if (driverId) {
+ await db.query(
+ `
+ INSERT INTO transit.shifts (schedule_id, driver_id)
+ VALUES ($1, $2)
+ `,
+ [schedule.id, driverId],
+ );
+ }
+
+ res.status(201).json({
+ schedule: {
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ status: schedule.status,
+ dayOfWeek: Number(schedule.day_of_week),
+ driverId,
+ },
+ });
+ } catch (err) {
+ console.error("POST /admin/weekly-schedules error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.post(
+ "/admin/weekly-schedules/bulk",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ const busLineId = Number(req.body?.busLineId);
+ const dayOfWeeks = Array.isArray(req.body?.dayOfWeeks)
+ ? req.body.dayOfWeeks
+ .map(Number)
+ .filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
+ : [];
+ const startTimes = Array.isArray(req.body?.startTimes)
+ ? [...new Set(req.body.startTimes.map(String).filter(Boolean))]
+ : [];
+ const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
+
+ if (
+ !Number.isInteger(busLineId) ||
+ dayOfWeeks.length === 0 ||
+ startTimes.length === 0
+ ) {
+ return res
+ .status(400)
+ .json({ error: "busLineId, dayOfWeeks and startTimes are required" });
}
- const hasWeekdaysColumn = await db.query(`
+ const client = await db.pool.connect();
+ try {
+ await client.query("BEGIN");
+ const line = await client.query(
+ "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
+ [busLineId],
+ );
+ if (line.rows.length === 0) {
+ await client.query("ROLLBACK");
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
+
+ const duration = Number(line.rows[0].estimated_duration_minutes) || 60;
+ const created = [];
+ for (const dayOfWeek of dayOfWeeks) {
+ for (const startTime of startTimes) {
+ const result = await client.query(
+ `INSERT INTO transit.schedules
+ (bus_line_id, start_time, end_time, status, day_of_week)
+ VALUES ($1, $2::time, ($2::time + ($3::text || ' minutes')::interval)::time, 'planned', $4)
+ RETURNING id, bus_line_id, start_time, end_time, status, day_of_week`,
+ [busLineId, startTime, duration, dayOfWeek],
+ );
+ const schedule = result.rows[0];
+ if (driverId) {
+ await client.query(
+ "INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)",
+ [schedule.id, driverId],
+ );
+ }
+ created.push(schedule);
+ }
+ }
+
+ await client.query("COMMIT");
+ res.status(201).json({
+ createdCount: created.length,
+ schedules: created.map((schedule) => ({
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ status: schedule.status,
+ dayOfWeek: Number(schedule.day_of_week),
+ driverId,
+ })),
+ });
+ } catch (err) {
+ await client.query("ROLLBACK");
+ console.error("POST /admin/weekly-schedules/bulk error:", err);
+ res.status(500).json({ error: "internal_error" });
+ } finally {
+ client.release();
+ }
+ },
+);
+
+app.put(
+ "/admin/weekly-schedules/:scheduleId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const scheduleId = Number(req.params.scheduleId);
+ const busLineId = Number(req.body?.busLineId);
+ const dayOfWeek = Number(req.body?.dayOfWeek);
+ const startTime = String(req.body?.startTime || "");
+ const endTime = String(req.body?.endTime || "");
+ if (
+ !Number.isInteger(scheduleId) ||
+ !Number.isInteger(busLineId) ||
+ !Number.isInteger(dayOfWeek) ||
+ dayOfWeek < 0 ||
+ dayOfWeek > 6 ||
+ !startTime ||
+ !endTime
+ ) {
+ return res.status(400).json({ error: "invalid_schedule" });
+ }
+
+ const result = await db.query(
+ `
+ UPDATE transit.schedules
+ SET bus_line_id = $2, start_time = $3::time, end_time = $4::time,
+ day_of_week = $5, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, bus_line_id, start_time, end_time, status, day_of_week
+ `,
+ [scheduleId, busLineId, startTime, endTime, dayOfWeek],
+ );
+ if (result.rows.length === 0)
+ return res.status(404).json({ error: "schedule_not_found" });
+ res.json({ schedule: result.rows[0] });
+ } catch (err) {
+ console.error("PUT /admin/weekly-schedules error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.put(
+ "/admin/weekly-schedules/:scheduleId/shift",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const scheduleId = Number(req.params.scheduleId);
+ const driverId = req.body?.driverId ? Number(req.body.driverId) : null;
+ if (!Number.isInteger(scheduleId))
+ return res.status(400).json({ error: "invalid_schedule_id" });
+ await db.query("DELETE FROM transit.shifts WHERE schedule_id = $1", [
+ scheduleId,
+ ]);
+ if (driverId) {
+ await db.query(
+ "INSERT INTO transit.shifts (schedule_id, driver_id) VALUES ($1, $2)",
+ [scheduleId, driverId],
+ );
+ }
+ res.json({ scheduleId, driverId });
+ } catch (err) {
+ console.error("PUT /admin/weekly-schedules/:id/shift error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.delete(
+ "/admin/weekly-schedules/:scheduleId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const scheduleId = Number(req.params.scheduleId);
+ if (!Number.isInteger(scheduleId))
+ return res.status(400).json({ error: "invalid_schedule_id" });
+ const result = await db.query(
+ "DELETE FROM transit.schedules WHERE id = $1 RETURNING id",
+ [scheduleId],
+ );
+ if (result.rows.length === 0)
+ return res.status(404).json({ error: "schedule_not_found" });
+ res.json({ success: true, scheduleId });
+ } catch (err) {
+ console.error("DELETE /admin/weekly-schedules error:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ },
+);
+
+app.get(
+ "/admin/schedules",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const templatesOnly =
+ String(
+ req.query.templates || req.query.includeTemplates || "",
+ ).toLowerCase() === "true" ||
+ String(req.query.templates || req.query.includeTemplates || "") === "1";
+
+ if (DEV_MOCK) {
+ const routeStopsByLine = new Map();
+ for (const routeStop of _mock.routeStops || []) {
+ const busLineId = Number(routeStop.busLineId);
+ if (!routeStopsByLine.has(busLineId)) {
+ routeStopsByLine.set(busLineId, []);
+ }
+ routeStopsByLine.get(busLineId).push({
+ stop_id: routeStop.stopId,
+ stop_order: routeStop.stopOrder,
+ });
+ }
+
+ const rawSchedules = (_mock.schedules || []).map((s) => {
+ const busLine = _mock.busLines.find((bl) => bl.id === s.busLineId);
+ const bus = _mock.fleetBuses.find((b) => b.id === s.busId);
+ return {
+ ...s,
+ busLineId: s.busLineId,
+ busId: s.busId,
+ startTime: s.startTime,
+ endTime: s.endTime,
+ weekdays: s.weekdays || [],
+ status: s.status,
+ lineNumber: busLine?.lineNumber || 0,
+ busName: bus?.name || "Unknown Bus",
+ driverId: s.driverId || null,
+ estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
+ };
+ });
+
+ const schedules = (
+ templatesOnly
+ ? rawSchedules
+ : expandRecurringScheduleRows(rawSchedules)
+ )
+ .filter((row) =>
+ scheduleMatchesFilters(
+ row,
+ parseScheduleFilters(req.query),
+ routeStopsByLine,
+ ),
+ )
+ .map((s) => ({
+ ...s,
+ departureTimes: calculateDepartureTimes(
+ s.startTime,
+ s.busLineId,
+ _mock,
+ ),
+ }))
+ .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+
+ return res.json({ schedules });
+ }
+
+ const hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
- const baseSelect = `
+ const baseSelect = `
SELECT
s.id,
s.bus_line_id,
@@ -2180,31 +3125,33 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
b.license_plate,
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name`;
- const result = hasWeekdaysColumn.rows.length > 0
- ? await db.query(`${baseSelect}, s.weekdays as weekdays
+ const result =
+ hasWeekdaysColumn.rows.length > 0
+ ? await db.query(`${baseSelect}, s.weekdays as weekdays
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
LEFT JOIN workers.employees e ON s.driver_id = e.id
ORDER BY s.start_time DESC`)
- : await db.query(`${baseSelect}, NULL as weekdays
+ : await db.query(`${baseSelect}, NULL as weekdays
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
LEFT JOIN workers.employees e ON s.driver_id = e.id
ORDER BY s.start_time DESC`);
- const lineIds = Array.from(
- new Set(
- result.rows
- .map(row => Number(row.bus_line_id))
- .filter(Number.isFinite)
- )
- );
+ const lineIds = Array.from(
+ new Set(
+ result.rows
+ .map((row) => Number(row.bus_line_id))
+ .filter(Number.isFinite),
+ ),
+ );
- const routeStopsByLine = new Map();
- if (lineIds.length > 0) {
- const routeStopsResult = await db.query(`
+ const routeStopsByLine = new Map();
+ if (lineIds.length > 0) {
+ const routeStopsResult = await db.query(
+ `
SELECT
rs.bus_line_id,
rs.stop_id,
@@ -2215,98 +3162,125 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = ANY($1::int[])
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
- `, [lineIds]);
+ `,
+ [lineIds],
+ );
- for (const row of routeStopsResult.rows) {
- const busLineId = Number(row.bus_line_id);
- if (!routeStopsByLine.has(busLineId)) {
- routeStopsByLine.set(busLineId, []);
+ for (const row of routeStopsResult.rows) {
+ const busLineId = Number(row.bus_line_id);
+ if (!routeStopsByLine.has(busLineId)) {
+ routeStopsByLine.set(busLineId, []);
+ }
+ routeStopsByLine.get(busLineId).push(row);
}
- routeStopsByLine.get(busLineId).push(row);
}
+
+ const filters = parseScheduleFilters(req.query);
+ const rowSource = templatesOnly
+ ? result.rows
+ : expandRecurringScheduleRows(result.rows);
+ const expandedSchedules = rowSource.filter((row) =>
+ scheduleMatchesFilters(row, filters, routeStopsByLine),
+ );
+
+ const schedules = expandedSchedules
+ .map((row) => mapAdminScheduleRow(row, routeStopsByLine))
+ .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+
+ res.json({ schedules });
+ } catch (err) {
+ console.error("GET /admin/schedules error:", err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
+ },
+);
- const filters = parseScheduleFilters(req.query);
- const rowSource = templatesOnly ? result.rows : expandRecurringScheduleRows(result.rows)
- const expandedSchedules = rowSource
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
+app.post(
+ "/admin/schedules",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { busLineId, busId, driverId, startTime, weekdays } = req.body;
- const schedules = expandedSchedules
- .map(row => mapAdminScheduleRow(row, routeStopsByLine))
- .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+ if (!busLineId || !busId || !startTime) {
+ return res.status(400).json({
+ error:
+ "missing_required_fields: busLineId, busId, startTime required",
+ });
+ }
- res.json({ schedules });
- } catch (err) {
- console.error('GET /admin/schedules error:', err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ // Validate weekdays - should be array of day numbers (0-6) or empty for single schedule
+ const scheduleWeekdays =
+ Array.isArray(weekdays) && weekdays.length > 0
+ ? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6)
+ : [];
-app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { busLineId, busId, driverId, startTime, weekdays } = req.body;
+ if (DEV_MOCK) {
+ if (!_mock.schedules) _mock.schedules = [];
- if (!busLineId || !busId || !startTime) {
- return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' });
- }
+ // Find the bus line to get duration
+ const busLine = _mock.busLines.find((bl) => bl.id == busLineId);
+ if (!busLine) {
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
- // Validate weekdays - should be array of day numbers (0-6) or empty for single schedule
- const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0
- ? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6)
- : [];
+ const startDate = new Date(startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(
+ endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
+ );
- if (DEV_MOCK) {
- if (!_mock.schedules) _mock.schedules = [];
-
- // Find the bus line to get duration
- const busLine = _mock.busLines.find(bl => bl.id == busLineId);
- if (!busLine) {
- return res.status(404).json({ error: 'bus_line_not_found' });
+ // Calculate departure times for stops
+ const departureTimes = calculateDepartureTimes(
+ startTime,
+ Number(busLineId),
+ _mock,
+ );
+
+ const schedule = {
+ id: _mock.nextScheduleId || 1,
+ busLineId,
+ busId,
+ driverId: driverId ? Number(driverId) : null,
+ startTime,
+ endTime: endDate.toISOString(),
+ weekdays: scheduleWeekdays,
+ departureTimes,
+ status: "planned",
+ createdAt: new Date().toISOString(),
+ };
+ _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
+ _mock.schedules.push(schedule);
+ return res.status(201).json({ schedule });
+ }
+
+ // Database mode (production)
+ const busLine = await db.query(
+ "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
+ [busLineId],
+ );
+ if (!busLine.rows[0]) {
+ return res.status(404).json({ error: "bus_line_not_found" });
}
const startDate = new Date(startTime);
const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60));
+ endDate.setMinutes(
+ endDate.getMinutes() +
+ (busLine.rows[0].estimated_duration_minutes || 60),
+ );
- // Calculate departure times for stops
- const departureTimes = calculateDepartureTimes(startTime, Number(busLineId), _mock);
-
- const schedule = {
- id: (_mock.nextScheduleId || 1),
- busLineId,
- busId,
- driverId: driverId ? Number(driverId) : null,
- startTime,
- endTime: endDate.toISOString(),
- weekdays: scheduleWeekdays,
- departureTimes,
- status: 'planned',
- createdAt: new Date().toISOString(),
- };
- _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1);
- _mock.schedules.push(schedule);
- return res.status(201).json({ schedule });
- }
-
- // Database mode (production)
- const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]);
- if (!busLine.rows[0]) {
- return res.status(404).json({ error: 'bus_line_not_found' });
- }
-
- const startDate = new Date(startTime);
- const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
-
- // Check if weekdays column exists before including it in INSERT
- const hasWeekdaysColumn = await db.query(`
+ // Check if weekdays column exists before including it in INSERT
+ const hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
-
- let result;
- if (hasWeekdaysColumn.rows.length > 0) {
- result = await db.query(`
+
+ let result;
+ if (hasWeekdaysColumn.rows.length > 0) {
+ result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
)
@@ -2314,9 +3288,19 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
- `, [busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]);
- } else {
- result = await db.query(`
+ `,
+ [
+ busLineId,
+ busId,
+ driverId || null,
+ startTime,
+ endDate.toISOString(),
+ JSON.stringify(scheduleWeekdays),
+ ],
+ );
+ } else {
+ result = await db.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
)
@@ -2324,106 +3308,144 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned'
FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
- `, [busLineId, busId, driverId || null, startTime, endDate.toISOString()]);
- }
-
- const schedule = result.rows[0];
- const departureTimes = calculateDepartureTimes(startTime, busLineId, _mock);
-
- const weekdaysResult = parseWeekdaysValue(schedule.weekdays);
-
- res.status(201).json({
- schedule: {
- id: Number(schedule.id),
- busLineId: Number(schedule.bus_line_id),
- busId: Number(schedule.bus_id),
- driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null,
- startTime: schedule.start_time,
- endTime: schedule.end_time,
- weekdays: weekdaysResult,
- departureTimes,
- status: schedule.status,
- createdAt: schedule.created_at,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { scheduleId } = req.params;
- const { busLineId, busId, driverId, startTime, weekdays } = req.body;
-
- if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
- return res.status(400).json({ error: 'invalid_schedule_id' });
- }
-
- if (!busLineId || !busId || !startTime) {
- return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' });
- }
-
- const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0
- ? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6)
- : [];
-
- if (DEV_MOCK) {
- const index = (_mock.schedules || []).findIndex(s => Number(s.id) === Number(scheduleId));
- if (index === -1) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ `,
+ [
+ busLineId,
+ busId,
+ driverId || null,
+ startTime,
+ endDate.toISOString(),
+ ],
+ );
}
- const busLine = _mock.busLines.find(bl => bl.id == busLineId);
- if (!busLine) {
- return res.status(404).json({ error: 'bus_line_not_found' });
+ const schedule = result.rows[0];
+ const departureTimes = calculateDepartureTimes(
+ startTime,
+ busLineId,
+ _mock,
+ );
+
+ const weekdaysResult = parseWeekdaysValue(schedule.weekdays);
+
+ res.status(201).json({
+ schedule: {
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ busId: Number(schedule.bus_id),
+ driverId:
+ schedule.driver_id !== null && schedule.driver_id !== undefined
+ ? Number(schedule.driver_id)
+ : null,
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ weekdays: weekdaysResult,
+ departureTimes,
+ status: schedule.status,
+ createdAt: schedule.created_at,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
+
+app.put(
+ "/admin/schedules/:scheduleId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { scheduleId } = req.params;
+ const { busLineId, busId, driverId, startTime, weekdays } = req.body;
+
+ if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
+ return res.status(400).json({ error: "invalid_schedule_id" });
+ }
+
+ if (!busLineId || !busId || !startTime) {
+ return res.status(400).json({
+ error:
+ "missing_required_fields: busLineId, busId, startTime required",
+ });
+ }
+
+ const scheduleWeekdays =
+ Array.isArray(weekdays) && weekdays.length > 0
+ ? weekdays.map((w) => Number(w)).filter((w) => w >= 0 && w <= 6)
+ : [];
+
+ if (DEV_MOCK) {
+ const index = (_mock.schedules || []).findIndex(
+ (s) => Number(s.id) === Number(scheduleId),
+ );
+ if (index === -1) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
+
+ const busLine = _mock.busLines.find((bl) => bl.id == busLineId);
+ if (!busLine) {
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
+
+ const startDate = new Date(startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(
+ endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
+ );
+
+ _mock.schedules[index] = {
+ ..._mock.schedules[index],
+ busLineId: Number(busLineId),
+ busId: Number(busId),
+ driverId: driverId ? Number(driverId) : null,
+ startTime,
+ endTime: endDate.toISOString(),
+ weekdays: scheduleWeekdays,
+ };
+
+ return res.json({ schedule: _mock.schedules[index] });
+ }
+
+ const existingResult = await db.query(
+ `
+ SELECT id, status
+ FROM transit.schedules
+ WHERE id = $1
+ `,
+ [scheduleId],
+ );
+
+ if (existingResult.rows.length === 0) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
+
+ const busLine = await db.query(
+ "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
+ [busLineId],
+ );
+ if (!busLine.rows[0]) {
+ return res.status(404).json({ error: "bus_line_not_found" });
}
const startDate = new Date(startTime);
const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60));
+ endDate.setMinutes(
+ endDate.getMinutes() +
+ (busLine.rows[0].estimated_duration_minutes || 60),
+ );
- _mock.schedules[index] = {
- ..._mock.schedules[index],
- busLineId: Number(busLineId),
- busId: Number(busId),
- driverId: driverId ? Number(driverId) : null,
- startTime,
- endTime: endDate.toISOString(),
- weekdays: scheduleWeekdays,
- };
-
- return res.json({ schedule: _mock.schedules[index] });
- }
-
- const existingResult = await db.query(`
- SELECT id, status
- FROM transit.schedules
- WHERE id = $1
- `, [scheduleId]);
-
- if (existingResult.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
- }
-
- const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]);
- if (!busLine.rows[0]) {
- return res.status(404).json({ error: 'bus_line_not_found' });
- }
-
- const startDate = new Date(startTime);
- const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
-
- const hasWeekdaysColumn = await db.query(`
+ const hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
- let result;
- if (hasWeekdaysColumn.rows.length > 0) {
- result = await db.query(`
+ let result;
+ if (hasWeekdaysColumn.rows.length > 0) {
+ result = await db.query(
+ `
UPDATE transit.schedules
SET
bus_line_id = $2,
@@ -2435,9 +3457,20 @@ app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (
updated_at = NOW()
WHERE id = $1
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
- `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]);
- } else {
- result = await db.query(`
+ `,
+ [
+ scheduleId,
+ busLineId,
+ busId,
+ driverId || null,
+ startTime,
+ endDate.toISOString(),
+ JSON.stringify(scheduleWeekdays),
+ ],
+ );
+ } else {
+ result = await db.query(
+ `
UPDATE transit.schedules
SET
bus_line_id = $2,
@@ -2448,117 +3481,164 @@ app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (
updated_at = NOW()
WHERE id = $1
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
- `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString()]);
- }
-
- const schedule = result.rows[0];
- return res.json({
- schedule: {
- id: Number(schedule.id),
- busLineId: Number(schedule.bus_line_id),
- busId: Number(schedule.bus_id),
- driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null,
- startTime: schedule.start_time,
- endTime: schedule.end_time,
- weekdays: parseWeekdaysValue(schedule.weekdays),
- status: schedule.status,
- createdAt: schedule.created_at,
- },
- });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
-
-app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, res) => {
- const requestedSchedules = Array.isArray(req.body?.schedules) ? req.body.schedules : [];
-
- if (requestedSchedules.length === 0) {
- return res.status(400).json({ error: 'missing_required_fields: schedules array required' });
- }
-
- const normalizedSchedules = requestedSchedules.map((schedule, index) => {
- const busLineId = Number(schedule?.busLineId);
- const busId = Number(schedule?.busId);
- const driverId = schedule?.driverId !== null && schedule?.driverId !== undefined && schedule?.driverId !== ''
- ? Number(schedule.driverId)
- : null;
- const startTime = schedule?.startTime ? String(schedule.startTime) : '';
- const weekdays = Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0
- ? schedule.weekdays.map(day => Number(day)).filter(day => day >= 0 && day <= 6)
- : [];
-
- if (!Number.isFinite(busLineId) || !Number.isFinite(busId) || !startTime) {
- throw new Error(`invalid_schedule_entry:${index}`);
- }
-
- return { busLineId, busId, driverId, startTime, weekdays };
- });
-
- try {
- if (DEV_MOCK) {
- if (!_mock.schedules) _mock.schedules = [];
-
- const createdSchedules = [];
-
- for (const entry of normalizedSchedules) {
- const busLine = _mock.busLines.find(bl => bl.id == entry.busLineId);
- if (!busLine) {
- return res.status(404).json({ error: 'bus_line_not_found' });
- }
-
- const startDate = new Date(entry.startTime);
- const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60));
-
- const departureTimes = calculateDepartureTimes(entry.startTime, Number(entry.busLineId), _mock);
-
- const schedule = {
- id: (_mock.nextScheduleId || 1),
- busLineId: entry.busLineId,
- busId: entry.busId,
- driverId: entry.driverId,
- startTime: entry.startTime,
- endTime: endDate.toISOString(),
- weekdays: entry.weekdays,
- departureTimes,
- status: 'planned',
- createdAt: new Date().toISOString(),
- };
-
- _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1);
- _mock.schedules.push(schedule);
- createdSchedules.push(schedule);
+ `,
+ [
+ scheduleId,
+ busLineId,
+ busId,
+ driverId || null,
+ startTime,
+ endDate.toISOString(),
+ ],
+ );
}
- return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules });
+ const schedule = result.rows[0];
+ return res.json({
+ schedule: {
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ busId: Number(schedule.bus_id),
+ driverId:
+ schedule.driver_id !== null && schedule.driver_id !== undefined
+ ? Number(schedule.driver_id)
+ : null,
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ weekdays: parseWeekdaysValue(schedule.weekdays),
+ status: schedule.status,
+ createdAt: schedule.created_at,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
+
+app.post(
+ "/admin/schedules/bulk",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ const requestedSchedules = Array.isArray(req.body?.schedules)
+ ? req.body.schedules
+ : [];
+
+ if (requestedSchedules.length === 0) {
+ return res
+ .status(400)
+ .json({ error: "missing_required_fields: schedules array required" });
}
- const dbClient = await db.pool.connect();
- try {
- await dbClient.query('BEGIN');
+ const normalizedSchedules = requestedSchedules.map((schedule, index) => {
+ const busLineId = Number(schedule?.busLineId);
+ const busId = Number(schedule?.busId);
+ const driverId =
+ schedule?.driverId !== null &&
+ schedule?.driverId !== undefined &&
+ schedule?.driverId !== ""
+ ? Number(schedule.driverId)
+ : null;
+ const startTime = schedule?.startTime ? String(schedule.startTime) : "";
+ const weekdays =
+ Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0
+ ? schedule.weekdays
+ .map((day) => Number(day))
+ .filter((day) => day >= 0 && day <= 6)
+ : [];
- const hasWeekdaysColumn = await dbClient.query(`
+ if (
+ !Number.isFinite(busLineId) ||
+ !Number.isFinite(busId) ||
+ !startTime
+ ) {
+ throw new Error(`invalid_schedule_entry:${index}`);
+ }
+
+ return { busLineId, busId, driverId, startTime, weekdays };
+ });
+
+ try {
+ if (DEV_MOCK) {
+ if (!_mock.schedules) _mock.schedules = [];
+
+ const createdSchedules = [];
+
+ for (const entry of normalizedSchedules) {
+ const busLine = _mock.busLines.find((bl) => bl.id == entry.busLineId);
+ if (!busLine) {
+ return res.status(404).json({ error: "bus_line_not_found" });
+ }
+
+ const startDate = new Date(entry.startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(
+ endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60),
+ );
+
+ const departureTimes = calculateDepartureTimes(
+ entry.startTime,
+ Number(entry.busLineId),
+ _mock,
+ );
+
+ const schedule = {
+ id: _mock.nextScheduleId || 1,
+ busLineId: entry.busLineId,
+ busId: entry.busId,
+ driverId: entry.driverId,
+ startTime: entry.startTime,
+ endTime: endDate.toISOString(),
+ weekdays: entry.weekdays,
+ departureTimes,
+ status: "planned",
+ createdAt: new Date().toISOString(),
+ };
+
+ _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
+ _mock.schedules.push(schedule);
+ createdSchedules.push(schedule);
+ }
+
+ return res.status(201).json({
+ createdCount: createdSchedules.length,
+ schedules: createdSchedules,
+ });
+ }
+
+ const dbClient = await db.pool.connect();
+ try {
+ await dbClient.query("BEGIN");
+
+ const hasWeekdaysColumn = await dbClient.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
- const createdSchedules = [];
+ const createdSchedules = [];
- for (const entry of normalizedSchedules) {
- const busLine = await dbClient.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [entry.busLineId]);
- if (!busLine.rows[0]) {
- throw new Error('bus_line_not_found');
- }
+ for (const entry of normalizedSchedules) {
+ const busLine = await dbClient.query(
+ "SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1",
+ [entry.busLineId],
+ );
+ if (!busLine.rows[0]) {
+ throw new Error("bus_line_not_found");
+ }
- const startDate = new Date(entry.startTime);
- const endDate = new Date(startDate);
- endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
+ const startDate = new Date(entry.startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(
+ endDate.getMinutes() +
+ (busLine.rows[0].estimated_duration_minutes || 60),
+ );
- let result;
- if (hasWeekdaysColumn.rows.length > 0) {
- result = await dbClient.query(`
+ let result;
+ if (hasWeekdaysColumn.rows.length > 0) {
+ result = await dbClient.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
)
@@ -2566,9 +3646,19 @@ app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, r
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
- `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString(), JSON.stringify(entry.weekdays)]);
- } else {
- result = await dbClient.query(`
+ `,
+ [
+ entry.busLineId,
+ entry.busId,
+ entry.driverId,
+ entry.startTime,
+ endDate.toISOString(),
+ JSON.stringify(entry.weekdays),
+ ],
+ );
+ } else {
+ result = await dbClient.query(
+ `
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
)
@@ -2576,83 +3666,115 @@ app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, r
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned'
FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
- `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString()]);
+ `,
+ [
+ entry.busLineId,
+ entry.busId,
+ entry.driverId,
+ entry.startTime,
+ endDate.toISOString(),
+ ],
+ );
+ }
+
+ const schedule = result.rows[0];
+ createdSchedules.push({
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ busId: Number(schedule.bus_id),
+ driverId:
+ schedule.driver_id !== null && schedule.driver_id !== undefined
+ ? Number(schedule.driver_id)
+ : null,
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ weekdays: parseWeekdaysValue(schedule.weekdays),
+ status: schedule.status,
+ createdAt: schedule.created_at,
+ });
}
- const schedule = result.rows[0];
- createdSchedules.push({
- id: Number(schedule.id),
- busLineId: Number(schedule.bus_line_id),
- busId: Number(schedule.bus_id),
- driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null,
- startTime: schedule.start_time,
- endTime: schedule.end_time,
- weekdays: parseWeekdaysValue(schedule.weekdays),
- status: schedule.status,
- createdAt: schedule.created_at,
+ await dbClient.query("COMMIT");
+ return res.status(201).json({
+ createdCount: createdSchedules.length,
+ schedules: createdSchedules,
});
+ } catch (err) {
+ await dbClient.query("ROLLBACK");
+ throw err;
+ } finally {
+ dbClient.release();
}
-
- await dbClient.query('COMMIT');
- return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules });
} catch (err) {
- await dbClient.query('ROLLBACK');
- throw err;
- } finally {
- dbClient.release();
+ console.error(err);
+ const errorMessage =
+ err.message === "bus_line_not_found"
+ ? "bus_line_not_found"
+ : err.message.startsWith("invalid_schedule_entry:")
+ ? err.message
+ : "internal_error";
+ const statusCode =
+ errorMessage === "bus_line_not_found"
+ ? 404
+ : errorMessage.startsWith("invalid_schedule_entry:")
+ ? 400
+ : 500;
+ res
+ .status(statusCode)
+ .json({ error: errorMessage, details: err.message });
}
- } catch (err) {
- console.error(err);
- const errorMessage = err.message === 'bus_line_not_found'
- ? 'bus_line_not_found'
- : err.message.startsWith('invalid_schedule_entry:')
- ? err.message
- : 'internal_error';
- const statusCode = errorMessage === 'bus_line_not_found' ? 404 : errorMessage.startsWith('invalid_schedule_entry:') ? 400 : 500;
- res.status(statusCode).json({ error: errorMessage, details: err.message });
- }
-});
+ },
+);
-app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { scheduleId } = req.params;
+app.delete(
+ "/admin/schedules/:scheduleId",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { scheduleId } = req.params;
- if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
- return res.status(400).json({ error: 'invalid_schedule_id' });
- }
-
- if (DEV_MOCK) {
- if (!_mock.schedules) _mock.schedules = [];
- const index = _mock.schedules.findIndex(s => Number(s.id) === Number(scheduleId));
- if (index === -1) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
+ return res.status(400).json({ error: "invalid_schedule_id" });
}
- const deleted = _mock.schedules.splice(index, 1)[0];
- return res.json({ success: true, schedule: deleted });
+
+ if (DEV_MOCK) {
+ if (!_mock.schedules) _mock.schedules = [];
+ const index = _mock.schedules.findIndex(
+ (s) => Number(s.id) === Number(scheduleId),
+ );
+ if (index === -1) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
+ const deleted = _mock.schedules.splice(index, 1)[0];
+ return res.json({ success: true, schedule: deleted });
+ }
+
+ const result = await db.query(
+ `DELETE FROM transit.schedules WHERE id = $1 RETURNING id`,
+ [scheduleId],
+ );
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
+
+ res.json({ success: true, schedule: { id: Number(result.rows[0].id) } });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
-
- const result = await db.query(
- `DELETE FROM transit.schedules WHERE id = $1 RETURNING id`,
- [scheduleId]
- );
-
- if (result.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
- }
-
- res.json({ success: true, schedule: { id: Number(result.rows[0].id) } });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ },
+);
// ============================================
// PUBLIC - Get schedules (for public timetable)
// ============================================
-app.get('/schedules', async (req, res) => {
+app.get("/schedules", async (req, res) => {
try {
- const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] });
+ const filters = parseScheduleFilters(req.query, {
+ statuses: ["planned", "active"],
+ });
if (DEV_MOCK) {
const routeStopsByLine = new Map();
@@ -2667,23 +3789,25 @@ app.get('/schedules', async (req, res) => {
});
}
- const schedules = expandRecurringScheduleRows((_mock.schedules || []).map(s => {
- const busLine = _mock.busLines.find(bl => bl.id === s.busLineId);
- const bus = _mock.fleetBuses.find(b => b.id === s.busId);
- return {
- id: s.id,
- busLineId: s.busLineId,
- busId: s.busId,
- startTime: s.startTime,
- endTime: s.endTime,
- weekdays: s.weekdays || [],
- status: s.status,
- lineNumber: busLine?.lineNumber || 0,
- busName: bus?.name || 'Unknown Bus',
- driverId: null,
- estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
- };
- })).filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
+ const schedules = expandRecurringScheduleRows(
+ (_mock.schedules || []).map((s) => {
+ const busLine = _mock.busLines.find((bl) => bl.id === s.busLineId);
+ const bus = _mock.fleetBuses.find((b) => b.id === s.busId);
+ return {
+ id: s.id,
+ busLineId: s.busLineId,
+ busId: s.busId,
+ startTime: s.startTime,
+ endTime: s.endTime,
+ weekdays: s.weekdays || [],
+ status: s.status,
+ lineNumber: busLine?.lineNumber || 0,
+ busName: bus?.name || "Unknown Bus",
+ driverId: null,
+ estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
+ };
+ }),
+ ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
return res.json({ schedules });
}
@@ -2733,11 +3857,18 @@ app.get('/schedules', async (req, res) => {
const result = await db.query(query, params);
- const lineIds = Array.from(new Set(result.rows.map(row => Number(row.bus_line_id)).filter(Number.isFinite)));
+ const lineIds = Array.from(
+ new Set(
+ result.rows
+ .map((row) => Number(row.bus_line_id))
+ .filter(Number.isFinite),
+ ),
+ );
const routeStopsByLine = new Map();
if (filters.stopId !== null && lineIds.length > 0) {
- const routeStopsResult = await db.query(`
+ const routeStopsResult = await db.query(
+ `
SELECT
rs.bus_line_id,
rs.stop_id,
@@ -2745,7 +3876,9 @@ app.get('/schedules', async (req, res) => {
FROM transit.route_stops rs
WHERE rs.bus_line_id = ANY($1::int[])
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
- `, [lineIds]);
+ `,
+ [lineIds],
+ );
for (const row of routeStopsResult.rows) {
const busLineId = Number(row.bus_line_id);
@@ -2757,8 +3890,8 @@ app.get('/schedules', async (req, res) => {
}
const schedules = expandRecurringScheduleRows(result.rows)
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine))
- .map(row => ({
+ .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
+ .map((row) => ({
id: Number(row.id),
busLineId: Number(row.bus_line_id),
busId: Number(row.bus_id),
@@ -2766,11 +3899,16 @@ app.get('/schedules', async (req, res) => {
driverId: null,
startTime: row.start_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
+ : [],
status: row.status,
lineNumber: row.line_number,
busName: row.bus_name,
- estimatedDurationMinutes: Number(row.estimated_duration_minutes) || null,
+ estimatedDurationMinutes:
+ Number(row.estimated_duration_minutes) || null,
isRecurringInstance: row.is_recurring_instance || false,
templateId: row.template_id || null,
}));
@@ -2778,13 +3916,15 @@ app.get('/schedules', async (req, res) => {
res.json({ schedules });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
-app.get('/schedules/overview', async (req, res) => {
+app.get("/schedules/overview", async (req, res) => {
try {
- const filters = parseScheduleFilters(req.query, { statuses: ['planned', 'active'] });
+ const filters = parseScheduleFilters(req.query, {
+ statuses: ["planned", "active"],
+ });
const overviewDateKey = filters.date || null;
if (DEV_MOCK) {
@@ -2801,14 +3941,20 @@ app.get('/schedules/overview', async (req, res) => {
stop_id: routeStop.stopId,
stop_order: routeStop.stopOrder,
estimated_arrival_minutes: routeStop.estimatedArrivalMinutes,
- stop_name: routeStop.name || '',
+ stop_name: routeStop.name || "",
});
}
- const baseRows = (_mock.schedules || []).map(schedule => {
- const busLine = _mock.busLines.find(line => Number(line.id) === Number(schedule.busLineId));
- const bus = _mock.fleetBuses.find(vehicle => Number(vehicle.id) === Number(schedule.busId));
- const driver = _mock.drivers.find(person => Number(person.id) === Number(schedule.driverId));
+ const baseRows = (_mock.schedules || []).map((schedule) => {
+ const busLine = _mock.busLines.find(
+ (line) => Number(line.id) === Number(schedule.busLineId),
+ );
+ const bus = _mock.fleetBuses.find(
+ (vehicle) => Number(vehicle.id) === Number(schedule.busId),
+ );
+ const driver = _mock.drivers.find(
+ (person) => Number(person.id) === Number(schedule.driverId),
+ );
return {
id: schedule.id,
@@ -2821,21 +3967,28 @@ app.get('/schedules/overview', async (req, res) => {
weekdays: schedule.weekdays || [],
status: schedule.status,
lineNumber: busLine?.lineNumber || 0,
- busName: bus?.name || 'Unknown Bus',
- licensePlate: bus?.licensePlate || '',
+ busName: bus?.name || "Unknown Bus",
+ licensePlate: bus?.licensePlate || "",
createdAt: schedule.createdAt,
};
});
- const schedules = mapRecurringOccurrenceRows(baseRows, overviewDateKey, routeStopsByLine)
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
+ const schedules = mapRecurringOccurrenceRows(
+ baseRows,
+ overviewDateKey,
+ routeStopsByLine,
+ ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
return res.json({
date: overviewDateKey,
summary: {
total: schedules.length,
- recurring: schedules.filter(schedule => schedule.scheduleType === 'recurring').length,
- oneTime: schedules.filter(schedule => schedule.scheduleType === 'one_time').length,
+ recurring: schedules.filter(
+ (schedule) => schedule.scheduleType === "recurring",
+ ).length,
+ oneTime: schedules.filter(
+ (schedule) => schedule.scheduleType === "one_time",
+ ).length,
},
schedules,
});
@@ -2862,34 +4015,40 @@ app.get('/schedules/overview', async (req, res) => {
b.license_plate AS "licensePlate",
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName"`;
- const result = hasWeekdaysColumn.rows.length > 0
- ? await db.query(`${baseSelect}, s.weekdays AS weekdays
+ const result =
+ hasWeekdaysColumn.rows.length > 0
+ ? await db.query(
+ `${baseSelect}, s.weekdays AS weekdays
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
LEFT JOIN workers.employees e ON s.driver_id = e.id
WHERE s.status = ANY($1::text[])
- ORDER BY s.start_time ASC`, [filters.statuses])
- : await db.query(`${baseSelect}, NULL AS weekdays
+ ORDER BY s.start_time ASC`,
+ [filters.statuses],
+ )
+ : await db.query(
+ `${baseSelect}, NULL AS weekdays
FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
LEFT JOIN workers.employees e ON s.driver_id = e.id
WHERE s.status = ANY($1::text[])
- ORDER BY s.start_time ASC`, [filters.statuses]);
+ ORDER BY s.start_time ASC`,
+ [filters.statuses],
+ );
const lineIds = Array.from(
new Set(
- result.rows
- .map(row => Number(row.busLineId))
- .filter(Number.isFinite)
- )
+ result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite),
+ ),
);
const routeStopsByLine = new Map();
if (lineIds.length > 0) {
- const routeStopsResult = await db.query(`
+ const routeStopsResult = await db.query(
+ `
SELECT
rs.bus_line_id,
rs.stop_id,
@@ -2900,7 +4059,9 @@ app.get('/schedules/overview', async (req, res) => {
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = ANY($1::int[])
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
- `, [lineIds]);
+ `,
+ [lineIds],
+ );
for (const row of routeStopsResult.rows) {
const busLineId = Number(row.bus_line_id);
@@ -2914,25 +4075,41 @@ app.get('/schedules/overview', async (req, res) => {
let schedules = [];
if (filters.fromDate && filters.toDate) {
- schedules = materializeScheduleRowsInRange(result.rows, filters.fromDate, filters.toDate, routeStopsByLine)
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
+ schedules = materializeScheduleRowsInRange(
+ result.rows,
+ filters.fromDate,
+ filters.toDate,
+ routeStopsByLine,
+ ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
} else {
- schedules = mapRecurringOccurrenceRows(result.rows, overviewDateKey, routeStopsByLine)
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
+ schedules = mapRecurringOccurrenceRows(
+ result.rows,
+ overviewDateKey,
+ routeStopsByLine,
+ ).filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine));
}
// Enrich schedules with live progress for the occurrence date(s)
if (!DEV_MOCK && schedules.length > 0) {
- const scheduleIds = [...new Set(schedules.map(s => Number(s.id)).filter(Number.isFinite))];
- const occurrenceDates = [...new Set(schedules.map(s => toDateKey(s.startTime)).filter(Boolean))];
+ const scheduleIds = [
+ ...new Set(schedules.map((s) => Number(s.id)).filter(Number.isFinite)),
+ ];
+ const occurrenceDates = [
+ ...new Set(
+ schedules.map((s) => toDateKey(s.startTime)).filter(Boolean),
+ ),
+ ];
if (scheduleIds.length > 0 && occurrenceDates.length > 0) {
- const progressRes = await db.query(`
+ const progressRes = await db.query(
+ `
SELECT schedule_id, scheduled_passed_date, stop_order, actual_passed_at, scheduled_passed_at, delay_minutes
FROM transit.schedule_stop_progress
WHERE schedule_id = ANY($1::int[]) AND scheduled_passed_date = ANY($2::date[])
ORDER BY schedule_id ASC, scheduled_passed_date ASC, actual_passed_at ASC
- `, [scheduleIds, occurrenceDates]);
+ `,
+ [scheduleIds, occurrenceDates],
+ );
const progressByKey = new Map();
for (const row of progressRes.rows) {
@@ -2941,20 +4118,33 @@ app.get('/schedules/overview', async (req, res) => {
progressByKey.get(key).push(row);
}
- schedules = schedules.map(s => {
+ schedules = schedules.map((s) => {
const key = `${s.id}|${toDateKey(s.startTime)}`;
const progRows = progressByKey.get(key) || [];
- const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || []).length;
+ const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || [])
+ .length;
const passedStops = progRows.length;
let currentDelayMinutes = null;
if (progRows.length > 0) {
const latest = progRows[progRows.length - 1];
- currentDelayMinutes = Number(latest.delay_minutes ?? latest.delay_minutes) || null;
+ currentDelayMinutes =
+ Number(latest.delay_minutes ?? latest.delay_minutes) || null;
}
- const statusOverride = passedStops >= totalStops && totalStops > 0 ? 'completed' : (passedStops > 0 ? 'in_progress' : s.status);
+ const statusOverride =
+ passedStops >= totalStops && totalStops > 0
+ ? "completed"
+ : passedStops > 0
+ ? "in_progress"
+ : s.status;
- return { ...s, status: statusOverride, passedStops, totalStops, currentDelayMinutes };
+ return {
+ ...s,
+ status: statusOverride,
+ passedStops,
+ totalStops,
+ currentDelayMinutes,
+ };
});
}
} else if (DEV_MOCK && schedules.length > 0) {
@@ -2966,15 +4156,30 @@ app.get('/schedules/overview', async (req, res) => {
progressByKey.get(key).push(p);
}
- schedules = schedules.map(s => {
+ schedules = schedules.map((s) => {
const key = `${s.id}|${toDateKey(s.startTime)}`;
const progRows = progressByKey.get(key) || [];
- const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || []).length;
+ const totalStops = (routeStopsByLine.get(Number(s.busLineId)) || [])
+ .length;
const passedStops = progRows.length;
- const latest = progRows.length > 0 ? progRows[progRows.length - 1] : null;
- const currentDelayMinutes = latest ? Number(latest.delayMinutes) || 0 : null;
- const statusOverride = passedStops >= totalStops && totalStops > 0 ? 'completed' : (passedStops > 0 ? 'in_progress' : s.status);
- return { ...s, status: statusOverride, passedStops, totalStops, currentDelayMinutes };
+ const latest =
+ progRows.length > 0 ? progRows[progRows.length - 1] : null;
+ const currentDelayMinutes = latest
+ ? Number(latest.delayMinutes) || 0
+ : null;
+ const statusOverride =
+ passedStops >= totalStops && totalStops > 0
+ ? "completed"
+ : passedStops > 0
+ ? "in_progress"
+ : s.status;
+ return {
+ ...s,
+ status: statusOverride,
+ passedStops,
+ totalStops,
+ currentDelayMinutes,
+ };
});
}
@@ -2982,21 +4187,25 @@ app.get('/schedules/overview', async (req, res) => {
date: overviewDateKey,
summary: {
total: schedules.length,
- recurring: schedules.filter(schedule => schedule.scheduleType === 'recurring').length,
- oneTime: schedules.filter(schedule => schedule.scheduleType === 'one_time').length,
+ recurring: schedules.filter(
+ (schedule) => schedule.scheduleType === "recurring",
+ ).length,
+ oneTime: schedules.filter(
+ (schedule) => schedule.scheduleType === "one_time",
+ ).length,
},
schedules,
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// PUBLIC - Get all recurring schedule templates (no date filtering)
// ============================================
-app.get('/schedules/recurring', async (req, res) => {
+app.get("/schedules/recurring", async (req, res) => {
try {
const filters = parseScheduleFilters(req.query);
@@ -3014,16 +4223,25 @@ app.get('/schedules/recurring', async (req, res) => {
stop_id: routeStop.stopId,
stop_order: routeStop.stopOrder,
estimated_arrival_minutes: routeStop.estimatedArrivalMinutes,
- stop_name: routeStop.name || '',
+ stop_name: routeStop.name || "",
});
}
const baseRows = (_mock.schedules || [])
- .filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0)
- .map(schedule => {
- const busLine = _mock.busLines.find(line => Number(line.id) === Number(schedule.busLineId));
- const bus = _mock.fleetBuses.find(vehicle => Number(vehicle.id) === Number(schedule.busId));
- const driver = _mock.drivers.find(person => Number(person.id) === Number(schedule.driverId));
+ .filter(
+ (schedule) =>
+ Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0,
+ )
+ .map((schedule) => {
+ const busLine = _mock.busLines.find(
+ (line) => Number(line.id) === Number(schedule.busLineId),
+ );
+ const bus = _mock.fleetBuses.find(
+ (vehicle) => Number(vehicle.id) === Number(schedule.busId),
+ );
+ const driver = _mock.drivers.find(
+ (person) => Number(person.id) === Number(schedule.driverId),
+ );
return {
id: schedule.id,
@@ -3035,16 +4253,20 @@ app.get('/schedules/recurring', async (req, res) => {
endTime: schedule.endTime,
weekdays: schedule.weekdays || [],
lineNumber: busLine?.lineNumber || 0,
- busName: bus?.name || 'Unknown Bus',
- licensePlate: bus?.licensePlate || '',
+ busName: bus?.name || "Unknown Bus",
+ licensePlate: bus?.licensePlate || "",
createdAt: schedule.createdAt,
};
});
const schedules = baseRows
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine))
- .map(row => {
- const materialized = materializeScheduleOverviewRow(row, null, routeStopsByLine);
+ .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
+ .map((row) => {
+ const materialized = materializeScheduleOverviewRow(
+ row,
+ null,
+ routeStopsByLine,
+ );
return {
...materialized,
status: undefined,
@@ -3084,11 +4306,16 @@ app.get('/schedules/recurring', async (req, res) => {
WHERE s.weekdays IS NOT NULL
`);
- const lineIds = Array.from(new Set(result.rows.map(row => Number(row.busLineId)).filter(Number.isFinite)));
+ const lineIds = Array.from(
+ new Set(
+ result.rows.map((row) => Number(row.busLineId)).filter(Number.isFinite),
+ ),
+ );
const routeStopsByLine = new Map();
if (lineIds.length > 0) {
- const routeStopsResult = await db.query(`
+ const routeStopsResult = await db.query(
+ `
SELECT
rs.bus_line_id,
rs.stop_id,
@@ -3099,7 +4326,9 @@ app.get('/schedules/recurring', async (req, res) => {
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = ANY($1::int[])
ORDER BY rs.bus_line_id ASC, rs.stop_order ASC
- `, [lineIds]);
+ `,
+ [lineIds],
+ );
for (const row of routeStopsResult.rows) {
const busLineId = Number(row.bus_line_id);
@@ -3111,9 +4340,13 @@ app.get('/schedules/recurring', async (req, res) => {
}
const schedules = result.rows
- .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine))
- .map(row => {
- const schedule = materializeScheduleOverviewRow(row, null, routeStopsByLine);
+ .filter((row) => scheduleMatchesFilters(row, filters, routeStopsByLine))
+ .map((row) => {
+ const schedule = materializeScheduleOverviewRow(
+ row,
+ null,
+ routeStopsByLine,
+ );
delete schedule.status;
return schedule;
});
@@ -3121,24 +4354,26 @@ app.get('/schedules/recurring', async (req, res) => {
res.json({ schedules });
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
-app.get('/schedules/:scheduleId', async (req, res) => {
+app.get("/schedules/:scheduleId", async (req, res) => {
try {
const { scheduleId } = req.params;
if (DEV_MOCK) {
- const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
+ const schedule = (_mock.schedules || []).find(
+ (s) => Number(s.id) === Number(scheduleId),
+ );
if (!schedule) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
-
+
// Enrich with bus line and fleet bus info
- const busLine = _mock.busLines.find(bl => bl.id === schedule.busLineId);
- const bus = _mock.fleetBuses.find(b => b.id === schedule.busId);
-
+ const busLine = _mock.busLines.find((bl) => bl.id === schedule.busLineId);
+ const bus = _mock.fleetBuses.find((b) => b.id === schedule.busId);
+
return res.json({
schedule: {
id: schedule.id,
@@ -3149,12 +4384,13 @@ app.get('/schedules/:scheduleId', async (req, res) => {
weekdays: schedule.weekdays || [],
status: schedule.status,
lineNumber: busLine?.lineNumber || 0,
- busName: bus?.name || 'Unknown Bus',
+ busName: bus?.name || "Unknown Bus",
},
});
}
- const result = await db.query(`
+ const result = await db.query(
+ `
SELECT
s.id,
s.bus_line_id,
@@ -3172,15 +4408,17 @@ app.get('/schedules/:scheduleId', async (req, res) => {
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
LEFT JOIN fleet.bus b ON s.bus_id = b.id
WHERE s.id = $1
- `, [scheduleId]);
+ `,
+ [scheduleId],
+ );
if (result.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
const row = result.rows[0];
const weekdays = parseWeekdaysValue(row.weekdays);
-
+
res.json({
schedule: {
id: Number(row.id),
@@ -3199,33 +4437,37 @@ app.get('/schedules/:scheduleId', async (req, res) => {
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// PUBLIC - Get stops for a schedule's line
// ============================================
-app.get('/schedules/:scheduleId/stops', async (req, res) => {
+app.get("/schedules/:scheduleId/stops", async (req, res) => {
try {
const { scheduleId } = req.params;
if (DEV_MOCK) {
- const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
+ const schedule = (_mock.schedules || []).find(
+ (s) => Number(s.id) === Number(scheduleId),
+ );
if (!schedule) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
const routeStops = (_mock.routeStops || [])
- .filter(rs => Number(rs.busLineId) === Number(schedule.busLineId))
+ .filter((rs) => Number(rs.busLineId) === Number(schedule.busLineId))
.sort((a, b) => a.stopOrder - b.stopOrder);
-
+
// Map to match database response format
- const formattedStops = routeStops.map(rs => {
- const stop = (_mock.stops || []).find(s => Number(s.id) === Number(rs.stopId));
+ const formattedStops = routeStops.map((rs) => {
+ const stop = (_mock.stops || []).find(
+ (s) => Number(s.id) === Number(rs.stopId),
+ );
return {
id: rs.id,
order: rs.stopOrder,
- name: stop?.name || rs.name || 'Unknown Stop',
+ name: stop?.name || rs.name || "Unknown Stop",
latitude: stop?.latitude ?? rs.latitude ?? 0,
longitude: stop?.longitude ?? rs.longitude ?? 0,
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
@@ -3236,16 +4478,17 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => {
const scheduleResult = await db.query(
`SELECT bus_line_id FROM transit.schedules WHERE id = $1`,
- [scheduleId]
+ [scheduleId],
);
if (scheduleResult.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
const busLineId = scheduleResult.rows[0].bus_line_id;
- const stopsResult = await db.query(`
+ const stopsResult = await db.query(
+ `
SELECT
rs.id,
rs.stop_order,
@@ -3257,10 +4500,12 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => {
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
- `, [busLineId]);
+ `,
+ [busLineId],
+ );
res.json({
- stops: stopsResult.rows.map(row => ({
+ stops: stopsResult.rows.map((row) => ({
id: row.id,
order: row.stop_order,
name: row.name,
@@ -3271,111 +4516,153 @@ app.get('/schedules/:scheduleId/stops', async (req, res) => {
});
} catch (err) {
console.error(err);
- res.status(500).json({ error: 'internal_error', details: err.message });
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// 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);
+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' });
+ if (
+ !Number.isFinite(scheduleId) ||
+ !Number.isFinite(stopOrder) ||
+ stopOrder < 1
+ ) {
+ return res
+ .status(400)
+ .json({ error: "invalid_schedule_or_stop_order" });
}
- 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 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" });
}
- const stop = (_mock.stops || []).find(s => Number(s.id) === Number(routeStop.stopId));
- // Allow caller to specify which occurrence date this pertains to (for recurring templates)
- const occurrenceDateKey = req.query.date || req.body.date || toDateKey(schedule.startTime);
- const templateStart = new Date(schedule.startTime);
- const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`);
- scheduledPassedAt.setHours(templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds());
- scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + (Number(routeStop.estimatedArrivalMinutes) || 0));
-
+ 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 existingIndex = (_mock.scheduleStopProgress || []).findIndex(
- p => Number(p.scheduleId) === scheduleId && Number(p.stopOrder) === stopOrder
- );
+ const routeStop = (_mock.routeStops || []).find(
+ (rs) =>
+ Number(rs.busLineId) === Number(schedule.busLineId) &&
+ Number(rs.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(),
- scheduledPassedDate: occurrenceDateKey,
- actualPassedAt: actualPassedAt.toISOString(),
- delayMinutes,
- markedByUserId: Number(req.user?.userId) || null,
- createdAt: new Date().toISOString(),
- };
+ if (!routeStop) {
+ return res.status(404).json({ error: "stop_not_found_for_schedule" });
+ }
- if (existingIndex >= 0) {
- _mock.scheduleStopProgress[existingIndex] = progressRow;
- } else {
- _mock.scheduleStopProgress.push(progressRow);
+ const stop = (_mock.stops || []).find(
+ (s) => Number(s.id) === Number(routeStop.stopId),
+ );
+ // Allow caller to specify which occurrence date this pertains to (for recurring templates)
+ const occurrenceDateKey =
+ req.query.date || req.body.date || toDateKey(schedule.startTime);
+ const templateStart = new Date(schedule.startTime);
+ const scheduledPassedAt = new Date(`${occurrenceDateKey}T00:00:00`);
+ scheduledPassedAt.setHours(
+ templateStart.getHours(),
+ templateStart.getMinutes(),
+ templateStart.getSeconds(),
+ templateStart.getMilliseconds(),
+ );
+ scheduledPassedAt.setMinutes(
+ scheduledPassedAt.getMinutes() +
+ (Number(routeStop.estimatedArrivalMinutes) || 0),
+ );
+
+ 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(),
+ scheduledPassedDate: occurrenceDateKey,
+ 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 &&
+ p.scheduledPassedDate === occurrenceDateKey,
+ ).length;
+
+ if (passedCount >= totalStops) {
+ schedule.status = "completed";
+ schedule.endTime = actualPassedAt.toISOString();
+ } else if (schedule.status === "planned") {
+ schedule.status = "in_progress";
+ }
+
+ return res.json({
+ scheduleId,
+ stopOrder,
+ status: schedule.status,
+ delayMinutes,
+ passedStops: passedCount,
+ totalStops,
+ occurrenceDate: occurrenceDateKey,
+ });
}
- const totalStops = (_mock.routeStops || []).filter(rs => Number(rs.busLineId) === Number(schedule.busLineId)).length;
- const passedCount = (_mock.scheduleStopProgress || []).filter(p => Number(p.scheduleId) === scheduleId && p.scheduledPassedDate === occurrenceDateKey).length;
+ await ensureScheduleStopProgressTable();
- if (passedCount >= totalStops) {
- schedule.status = 'completed';
- schedule.endTime = actualPassedAt.toISOString();
- } else if (schedule.status === 'planned') {
- schedule.status = 'in_progress';
- }
-
- return res.json({
- scheduleId,
- stopOrder,
- status: schedule.status,
- delayMinutes,
- passedStops: passedCount,
- totalStops,
- occurrenceDate: occurrenceDateKey,
- });
- }
-
- await ensureScheduleStopProgressTable();
-
- const scheduleResult = await db.query(`
+ 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]);
+ `,
+ [scheduleId],
+ );
- if (scheduleResult.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
- }
+ if (scheduleResult.rows.length === 0) {
+ return res.status(404).json({ error: "schedule_not_found" });
+ }
- const schedule = scheduleResult.rows[0];
+ const schedule = scheduleResult.rows[0];
- const routeStopResult = await db.query(`
+ const routeStopResult = await db.query(
+ `
SELECT
rs.stop_id,
rs.stop_order,
@@ -3386,82 +4673,125 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware
WHERE rs.bus_line_id = $1
AND rs.stop_order = $2
LIMIT 1
- `, [schedule.bus_line_id, stopOrder]);
+ `,
+ [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];
- // Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date)
- 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 (routeStopResult.rows.length === 0) {
+ return res.status(404).json({ error: "stop_not_found_for_schedule" });
}
- 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]
- );
+ const routeStop = routeStopResult.rows[0];
+ // Determine occurrence date (for recurring templates callers can pass ?date=YYYY-MM-DD or body.date)
+ let occurrenceDateKey =
+ req.query.date || req.body.date || toDateKey(schedule.start_time);
- 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());
+ // 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;
+ }
- 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);
- }
+ 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],
+ );
- const hasTemplateIdCol = await db.query(`
+ 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(`
+ 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(`
+ `,
+ [
+ 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]);
- }
+ `,
+ [
+ schedule.bus_line_id,
+ schedule.bus_id,
+ schedule.driver_id,
+ occurrenceStart.toISOString(),
+ occurrenceEnd ? occurrenceEnd.toISOString() : null,
+ ],
+ );
+ }
- instanceScheduleId = insertRes.rows[0].id;
+ 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());
- scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + (Number(routeStop.estimated_arrival_minutes) || 0));
+ 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(),
+ );
+ scheduledPassedAt.setMinutes(
+ scheduledPassedAt.getMinutes() +
+ (Number(routeStop.estimated_arrival_minutes) || 0),
+ );
- await db.query(`
+ await db.query(
+ `
INSERT INTO transit.schedule_stop_progress (
schedule_id,
bus_line_id,
@@ -3480,113 +4810,161 @@ app.post('/driver/schedules/:scheduleId/stops/:stopOrder/passed', authMiddleware
actual_passed_at = EXCLUDED.actual_passed_at,
marked_by_user_id = EXCLUDED.marked_by_user_id,
created_at = NOW()
- `, [
- instanceScheduleId,
- Number(schedule.bus_line_id),
- Number(routeStop.stop_id),
- stopOrder,
- routeStop.stop_name,
- Number(routeStop.estimated_arrival_minutes) || 0,
- scheduledPassedAt.toISOString(),
- occurrenceDateKey,
- actualPassedAt.toISOString(),
- Number(req.user?.userId) || null,
- ]);
+ `,
+ [
+ instanceScheduleId,
+ Number(schedule.bus_line_id),
+ Number(routeStop.stop_id),
+ stopOrder,
+ routeStop.stop_name,
+ Number(routeStop.estimated_arrival_minutes) || 0,
+ scheduledPassedAt.toISOString(),
+ occurrenceDateKey,
+ actualPassedAt.toISOString(),
+ Number(req.user?.userId) || null,
+ ],
+ );
- const totalsResult = await db.query(`
+ 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 AND scheduled_passed_date = $3) AS passed_stops
- `, [schedule.bus_line_id, instanceScheduleId, 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);
- const isCompleted = totalStops > 0 && passedStops >= totalStops;
- const nextStatus = isCompleted ? 'completed' : 'in_progress';
+ 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" : "in_progress";
- await db.query(`
+ await db.query(
+ `
UPDATE transit.schedules
SET
status = $2,
end_time = CASE WHEN $2 = 'completed' THEN $3 ELSE end_time END
WHERE id = $1
- `, [instanceScheduleId, nextStatus, actualPassedAt.toISOString()]);
+ `,
+ [instanceScheduleId, nextStatus, actualPassedAt.toISOString()],
+ );
- // Calculate delay live
- const delayMinutes = Math.round((actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000);
+ // Calculate delay live
+ const delayMinutes = Math.round(
+ (actualPassedAt.getTime() - scheduledPassedAt.getTime()) / 60000,
+ );
- 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 });
- }
-});
+ 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) => {
+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' });
+ return res.status(400).json({ error: "invalid_schedule_id" });
}
if (DEV_MOCK) {
- const schedule = (_mock.schedules || []).find(s => Number(s.id) === scheduleId);
+ const schedule = (_mock.schedules || []).find(
+ (s) => Number(s.id) === scheduleId,
+ );
if (!schedule) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
- const busLine = (_mock.busLines || []).find(bl => Number(bl.id) === Number(schedule.busLineId));
+ const busLine = (_mock.busLines || []).find(
+ (bl) => Number(bl.id) === Number(schedule.busLineId),
+ );
const routeStops = (_mock.routeStops || [])
- .filter(rs => Number(rs.busLineId) === Number(schedule.busLineId))
+ .filter((rs) => Number(rs.busLineId) === Number(schedule.busLineId))
.sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder));
- const mockProgressDate = req.query.date || req.query.occurrenceDate || null;
- let progressEvents = (_mock.scheduleStopProgress || []).filter(p => Number(p.scheduleId) === scheduleId);
+ const mockProgressDate =
+ req.query.date || req.query.occurrenceDate || null;
+ let progressEvents = (_mock.scheduleStopProgress || []).filter(
+ (p) => Number(p.scheduleId) === scheduleId,
+ );
if (mockProgressDate) {
- progressEvents = progressEvents.filter(p => p.scheduledPassedDate === mockProgressDate);
+ progressEvents = progressEvents.filter(
+ (p) => p.scheduledPassedDate === mockProgressDate,
+ );
}
- progressEvents = progressEvents.sort((a, b) => Number(a.stopOrder) - Number(b.stopOrder));
+ progressEvents = progressEvents.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 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 occurrenceDateKey = req.query.date || req.query.occurrenceDate || toDateKey(schedule.startTime);
- const stops = routeStops.map(rs => {
- const stop = (_mock.stops || []).find(s => Number(s.id) === Number(rs.stopId));
+ const occurrenceDateKey =
+ req.query.date ||
+ req.query.occurrenceDate ||
+ toDateKey(schedule.startTime);
+ const stops = routeStops.map((rs) => {
+ const stop = (_mock.stops || []).find(
+ (s) => Number(s.id) === Number(rs.stopId),
+ );
const templateStart = new Date(schedule.startTime);
const scheduledTime = new Date(`${occurrenceDateKey}T00:00:00`);
- scheduledTime.setHours(templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds());
- scheduledTime.setMinutes(scheduledTime.getMinutes() + (Number(rs.estimatedArrivalMinutes) || 0));
+ scheduledTime.setHours(
+ templateStart.getHours(),
+ templateStart.getMinutes(),
+ templateStart.getSeconds(),
+ templateStart.getMilliseconds(),
+ );
+ 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',
+ 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',
+ 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');
+ 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: {
@@ -3605,7 +4983,10 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
nextStop: nextStop
? {
...nextStop,
- predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(),
+ predictedPassedAt: new Date(
+ new Date(nextStop.scheduledPassedAt).getTime() +
+ currentDelayMinutes * 60000,
+ ).toISOString(),
}
: null,
stops,
@@ -3615,7 +4996,8 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
await ensureScheduleStopProgressTable();
- const scheduleResult = await db.query(`
+ const scheduleResult = await db.query(
+ `
SELECT
s.id,
s.bus_line_id,
@@ -3629,15 +5011,18 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
JOIN transit.bus_lines bl ON bl.id = s.bus_line_id
WHERE s.id = $1
LIMIT 1
- `, [scheduleId]);
+ `,
+ [scheduleId],
+ );
if (scheduleResult.rows.length === 0) {
- return res.status(404).json({ error: 'schedule_not_found' });
+ return res.status(404).json({ error: "schedule_not_found" });
}
const schedule = scheduleResult.rows[0];
- const routeStopsResult = await db.query(`
+ const routeStopsResult = await db.query(
+ `
SELECT
rs.stop_id,
rs.stop_order,
@@ -3647,13 +5032,16 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
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]);
+ `,
+ [schedule.bus_line_id],
+ );
// Allow querying progress for a specific occurrence date (recurring templates)
const progressDate = req.query.date || req.query.occurrenceDate || null;
let progressResult;
if (progressDate) {
- progressResult = await db.query(`
+ progressResult = await db.query(
+ `
SELECT
stop_order,
actual_passed_at,
@@ -3662,9 +5050,12 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
FROM transit.schedule_stop_progress
WHERE schedule_id = $1 AND scheduled_passed_date = $2
ORDER BY stop_order ASC
- `, [scheduleId, progressDate]);
+ `,
+ [scheduleId, progressDate],
+ );
} else {
- progressResult = await db.query(`
+ progressResult = await db.query(
+ `
SELECT
stop_order,
actual_passed_at,
@@ -3673,31 +5064,48 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
FROM transit.schedule_stop_progress
WHERE schedule_id = $1
ORDER BY scheduled_passed_at ASC, stop_order ASC
- `, [scheduleId]);
+ `,
+ [scheduleId],
+ );
}
const progressByOrder = new Map(
- progressResult.rows.map(row => [Number(row.stop_order), row])
+ progressResult.rows.map((row) => [Number(row.stop_order), row]),
);
- const latestProgress = progressResult.rows.length > 0
- ? progressResult.rows[progressResult.rows.length - 1]
- : null;
+ const latestProgress =
+ progressResult.rows.length > 0
+ ? progressResult.rows[progressResult.rows.length - 1]
+ : null;
// Calculate delay live from latest passed stop
let currentDelayMinutes = null;
- if (latestProgress && latestProgress.actual_passed_at && latestProgress.scheduled_passed_at) {
+ if (
+ latestProgress &&
+ latestProgress.actual_passed_at &&
+ latestProgress.scheduled_passed_at
+ ) {
const actualTime = new Date(latestProgress.actual_passed_at);
const scheduledTime = new Date(latestProgress.scheduled_passed_at);
- currentDelayMinutes = Math.round((actualTime.getTime() - scheduledTime.getTime()) / 60000);
+ currentDelayMinutes = Math.round(
+ (actualTime.getTime() - scheduledTime.getTime()) / 60000,
+ );
}
- const occurrenceDateKeyForMapping = progressDate || toDateKey(schedule.start_time);
+ const occurrenceDateKeyForMapping =
+ progressDate || toDateKey(schedule.start_time);
- const stops = routeStopsResult.rows.map(row => {
+ const stops = routeStopsResult.rows.map((row) => {
const etaMinutes = Number(row.estimated_arrival_minutes) || 0;
const templateStart = new Date(schedule.start_time);
- const scheduledPassedAt = new Date(`${occurrenceDateKeyForMapping}T00:00:00`);
- scheduledPassedAt.setHours(templateStart.getHours(), templateStart.getMinutes(), templateStart.getSeconds(), templateStart.getMilliseconds());
+ const scheduledPassedAt = new Date(
+ `${occurrenceDateKeyForMapping}T00:00:00`,
+ );
+ scheduledPassedAt.setHours(
+ templateStart.getHours(),
+ templateStart.getMinutes(),
+ templateStart.getSeconds(),
+ templateStart.getMilliseconds(),
+ );
scheduledPassedAt.setMinutes(scheduledPassedAt.getMinutes() + etaMinutes);
const progress = progressByOrder.get(Number(row.stop_order));
@@ -3713,20 +5121,25 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
if (progress?.actual_passed_at && progress?.scheduled_passed_at) {
const actualTime = new Date(progress.actual_passed_at);
const scheduledTime = new Date(progress.scheduled_passed_at);
- return Math.round((actualTime.getTime() - scheduledTime.getTime()) / 60000);
+ return Math.round(
+ (actualTime.getTime() - scheduledTime.getTime()) / 60000,
+ );
}
return null;
})(),
- status: progress ? 'passed' : 'pending',
+ status: progress ? "passed" : "pending",
};
});
- const passedStops = stops.filter(s => s.status === 'passed');
- const nextStop = stops.find(s => s.status === 'pending') || null;
+ 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');
+ const driverState =
+ passedStops.length === 0
+ ? "not_started"
+ : passedStops.length >= stops.length
+ ? "completed"
+ : "en_route";
res.json({
schedule: {
@@ -3745,130 +5158,157 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
nextStop: nextStop
? {
...nextStop,
- predictedPassedAt: new Date(new Date(nextStop.scheduledPassedAt).getTime() + currentDelayMinutes * 60000).toISOString(),
+ 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 });
+ console.error("GET /public/schedules/:scheduleId/progress error:", err);
+ res.status(500).json({ error: "internal_error", details: err.message });
}
});
// ============================================
// ADMIN - Check driver conflicts for generated schedules
// ============================================
-app.post('/admin/check-driver-conflicts', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { generatedTimes, drivers, busLineId, busId, weekdays = [] } = req.body;
+app.post(
+ "/admin/check-driver-conflicts",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const {
+ generatedTimes,
+ drivers,
+ busLineId,
+ busId,
+ weekdays = [],
+ } = req.body;
- if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) {
- return res.status(400).json({ error: 'missing_required_fields' });
- }
+ if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) {
+ return res.status(400).json({ error: "missing_required_fields" });
+ }
- // Get all existing schedules
- const schedResult = DEV_MOCK
- ? { rows: _mock.schedules || [] }
- : await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`);
+ // Get all existing schedules
+ const schedResult = DEV_MOCK
+ ? { rows: _mock.schedules || [] }
+ : await db.query(
+ `SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`,
+ );
- const existingSchedules = schedResult.rows.map(row => ({
- ...row,
- weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || '[]'),
- }));
+ const existingSchedules = schedResult.rows.map((row) => ({
+ ...row,
+ weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || "[]"),
+ }));
- // For each generated time, check conflicts with each driver
- const assignments = generatedTimes.map(timeStr => {
- const driverAssignments = drivers.map(driver => {
- const hasConflict = checkDriverConflict(
- driver.id,
- timeStr,
- timeStr, // Will be checked with start/end times of conflicts
- weekdays,
- existingSchedules
- );
+ // For each generated time, check conflicts with each driver
+ const assignments = generatedTimes.map((timeStr) => {
+ const driverAssignments = drivers.map((driver) => {
+ const hasConflict = checkDriverConflict(
+ driver.id,
+ timeStr,
+ timeStr, // Will be checked with start/end times of conflicts
+ weekdays,
+ existingSchedules,
+ );
+
+ return {
+ driverId: driver.id,
+ driverName: driver.name,
+ hasConflict,
+ assigned: false,
+ };
+ });
return {
- driverId: driver.id,
- driverName: driver.name,
- hasConflict,
- assigned: false,
+ time: timeStr,
+ driverAssignments,
+ assignedDriverId: null,
};
});
- return {
- time: timeStr,
- driverAssignments,
- assignedDriverId: null,
- };
- });
-
- res.json({ assignments });
- } catch (err) {
- console.error('/admin/check-driver-conflicts error:', err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ res.json({ assignments });
+ } catch (err) {
+ console.error("/admin/check-driver-conflicts error:", err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
// ============================================
// ADMIN - Auto-assign schedules to drivers
// ============================================
-app.post('/admin/assign-schedules', authMiddleware, adminMiddleware, async (req, res) => {
- try {
- const { assignments, busLineId, busId, weekdays = [] } = req.body;
+app.post(
+ "/admin/assign-schedules",
+ authMiddleware,
+ adminMiddleware,
+ async (req, res) => {
+ try {
+ const { assignments, busLineId, busId, weekdays = [] } = req.body;
- if (!Array.isArray(assignments)) {
- return res.status(400).json({ error: 'missing_assignments' });
- }
-
- // Group assignments by driver (round-robin or custom)
- const schedulesToCreate = [];
- let driverIndex = 0;
-
- for (const assignment of assignments) {
- if (!assignment.time || !assignment.assignedDriverId) continue;
-
- const startTime = assignment.time;
- const endTime = assignment.endTime;
-
- schedulesToCreate.push({
- busLineId,
- busId,
- driverId: assignment.assignedDriverId,
- startTime,
- endTime,
- weekdays,
- });
- }
-
- // Use bulk create endpoint logic
- if (DEV_MOCK) {
- if (!_mock.schedules) _mock.schedules = [];
- const createdSchedules = [];
-
- for (const entry of schedulesToCreate) {
- const schedule = {
- id: (_mock.nextScheduleId || 1),
- ...entry,
- status: 'planned',
- createdAt: new Date().toISOString(),
- };
- _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1);
- _mock.schedules.push(schedule);
- createdSchedules.push(schedule);
+ if (!Array.isArray(assignments)) {
+ return res.status(400).json({ error: "missing_assignments" });
}
- return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules });
- }
+ // Group assignments by driver (round-robin or custom)
+ const schedulesToCreate = [];
+ let driverIndex = 0;
- // For DB: use bulk insert
- res.status(201).json({ createdCount: schedulesToCreate.length, message: 'would_insert_schedules' });
- } catch (err) {
- console.error('/admin/assign-schedules error:', err);
- res.status(500).json({ error: 'internal_error', details: err.message });
- }
-});
+ for (const assignment of assignments) {
+ if (!assignment.time || !assignment.assignedDriverId) continue;
+
+ const startTime = assignment.time;
+ const endTime = assignment.endTime;
+
+ schedulesToCreate.push({
+ busLineId,
+ busId,
+ driverId: assignment.assignedDriverId,
+ startTime,
+ endTime,
+ weekdays,
+ });
+ }
+
+ // Use bulk create endpoint logic
+ if (DEV_MOCK) {
+ if (!_mock.schedules) _mock.schedules = [];
+ const createdSchedules = [];
+
+ for (const entry of schedulesToCreate) {
+ const schedule = {
+ id: _mock.nextScheduleId || 1,
+ ...entry,
+ status: "planned",
+ createdAt: new Date().toISOString(),
+ };
+ _mock.nextScheduleId = (_mock.nextScheduleId || 1) + 1;
+ _mock.schedules.push(schedule);
+ createdSchedules.push(schedule);
+ }
+
+ return res.status(201).json({
+ createdCount: createdSchedules.length,
+ schedules: createdSchedules,
+ });
+ }
+
+ // For DB: use bulk insert
+ res.status(201).json({
+ createdCount: schedulesToCreate.length,
+ message: "would_insert_schedules",
+ });
+ } catch (err) {
+ console.error("/admin/assign-schedules error:", err);
+ res.status(500).json({ error: "internal_error", details: err.message });
+ }
+ },
+);
const PORT = process.env.PORT || 5001;
@@ -3876,11 +5316,11 @@ const PORT = process.env.PORT || 5001;
(async () => {
try {
await ensureScheduleStopProgressTable();
- console.log('✓ Database tables initialized');
+ console.log("✓ Database tables initialized");
} catch (err) {
- console.error('⚠ Error initializing tables:', err.message);
+ console.error("⚠ Error initializing tables:", err.message);
}
-
+
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
})();
diff --git a/windows_xp_driver_ui/Makefile b/windows_xp_driver_ui/Makefile
new file mode 100644
index 0000000..e106a7c
--- /dev/null
+++ b/windows_xp_driver_ui/Makefile
@@ -0,0 +1,12 @@
+CC = i686-w64-mingw32-gcc
+CFLAGS = -O2 -Wall -D_WIN32_WINNT=0x0501 -mwindows
+LDFLAGS = -lwinhttp
+TARGET = driver_xp.exe
+
+all: $(TARGET)
+
+$(TARGET): driver_xp.c
+ $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
+
+clean:
+ rm -f $(TARGET)
diff --git a/windows_xp_driver_ui/README.md b/windows_xp_driver_ui/README.md
new file mode 100644
index 0000000..972e61e
--- /dev/null
+++ b/windows_xp_driver_ui/README.md
@@ -0,0 +1,27 @@
+# Transit Driver XP
+
+Native Win32 driver client targeting Windows XP-era systems.
+
+The client authenticates with `auth_api` at `192.168.68.66:5000`, then requests
+assigned weekly schedule templates from `transit_api` at
+`192.168.68.66:5001/driver/schedules/daily`.
+
+Schedules are assigned through `transit.shifts`; the client does not expect a
+`driver_id`, `bus_id`, or `weekdays` column on `transit.schedules`.
+
+## Build
+
+```bash
+make clean && make
+```
+
+## Distribution
+
+Build the installer with:
+
+```bash
+makensis installer.nsi
+```
+
+Distribute `TransitDriverXPSetup.exe`. It detects an existing installation,
+stops the previous client, and replaces it in the same directory.
diff --git a/windows_xp_driver_ui/TransitDriverXPSetup.exe b/windows_xp_driver_ui/TransitDriverXPSetup.exe
new file mode 100644
index 0000000..d3b05d4
Binary files /dev/null and b/windows_xp_driver_ui/TransitDriverXPSetup.exe differ
diff --git a/windows_xp_driver_ui/driver_xp.c b/windows_xp_driver_ui/driver_xp.c
new file mode 100644
index 0000000..403605c
--- /dev/null
+++ b/windows_xp_driver_ui/driver_xp.c
@@ -0,0 +1,1050 @@
+#define _WIN32_WINNT 0x0501
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define ID_USERNAME 101
+#define ID_PASSWORD 102
+#define ID_LOGIN 103
+#define ID_REFRESH 104
+#define ID_OUTPUT 105
+#define ID_STATUS 106
+#define ID_DATE 107
+#define ID_ROUTES 108
+#define ID_STOPS 109
+#define ID_BACK 110
+#define ID_HALT 111
+#define ID_PAGE_TITLE 112
+#define ID_DATE_LABEL 113
+#define ID_USER_LABEL 114
+#define ID_PASSWORD_LABEL 115
+#define ID_ROUTES_LABEL 116
+
+#define MAX_BUF 65535
+#define API_HOST "192.168.68.66"
+#define AUTH_PORT 5000
+#define TRANSIT_PORT 5001
+
+typedef struct {
+ HWND hwnd;
+ char token[512];
+ char lastText[8192];
+ int routeCount;
+ int routeLineIds[32];
+ char routeLabels[32][256];
+ int stopCount;
+ int activeStop;
+ char stopNames[64][128];
+ char stopTimes[64][32];
+} APPSTATE;
+
+static void SetStatusText(HWND hwnd, const char *text)
+{
+ SetWindowTextA(GetDlgItem(hwnd, ID_STATUS), text);
+}
+
+static void SetOutputText(HWND hwnd, const char *text)
+{
+ SetWindowTextA(GetDlgItem(hwnd, ID_OUTPUT), text);
+}
+
+static void ShowControl(HWND hwnd, int id, int visible)
+{
+ ShowWindow(GetDlgItem(hwnd, id), visible ? SW_SHOW : SW_HIDE);
+}
+
+static void ShowRouteScreen(HWND hwnd)
+{
+ SetWindowTextA(hwnd, "Transit Driver XP - Assigned Routes");
+ SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "TODAY'S ASSIGNED ROUTES");
+ ShowControl(hwnd, ID_DATE, 1);
+ ShowControl(hwnd, ID_DATE_LABEL, 1);
+ ShowControl(hwnd, ID_ROUTES, 1);
+ ShowControl(hwnd, ID_ROUTES_LABEL, 1);
+ ShowControl(hwnd, ID_REFRESH, 1);
+ ShowControl(hwnd, ID_STOPS, 1);
+ ShowControl(hwnd, ID_OUTPUT, 1);
+ ShowControl(hwnd, ID_BACK, 0);
+ ShowControl(hwnd, ID_HALT, 0);
+ ShowControl(hwnd, ID_USERNAME, 0);
+ ShowControl(hwnd, ID_PASSWORD, 0);
+ ShowControl(hwnd, ID_USER_LABEL, 0);
+ ShowControl(hwnd, ID_PASSWORD_LABEL, 0);
+ ShowControl(hwnd, ID_LOGIN, 0);
+}
+
+static void ShowStartScreen(HWND hwnd, APPSTATE *state)
+{
+ char title[256];
+ SetWindowTextA(hwnd, "Transit Driver XP - Route in Progress");
+ SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "ROUTE IN PROGRESS");
+ ShowControl(hwnd, ID_DATE, 0);
+ ShowControl(hwnd, ID_DATE_LABEL, 0);
+ ShowControl(hwnd, ID_ROUTES, 0);
+ ShowControl(hwnd, ID_ROUTES_LABEL, 0);
+ ShowControl(hwnd, ID_REFRESH, 0);
+ ShowControl(hwnd, ID_STOPS, 0);
+ ShowControl(hwnd, ID_OUTPUT, 0);
+ ShowControl(hwnd, ID_BACK, 1);
+ ShowControl(hwnd, ID_HALT, 1);
+ ShowControl(hwnd, ID_USERNAME, 0);
+ ShowControl(hwnd, ID_PASSWORD, 0);
+ ShowControl(hwnd, ID_USER_LABEL, 0);
+ ShowControl(hwnd, ID_PASSWORD_LABEL, 0);
+ ShowControl(hwnd, ID_LOGIN, 0);
+ snprintf(title, sizeof(title), "Route started\r\n\r\nTap the halt button when you arrive at the displayed halt.");
+ SetWindowTextA(GetDlgItem(hwnd, ID_STATUS), "Route in progress");
+ SetWindowTextA(GetDlgItem(hwnd, ID_OUTPUT), title);
+ InvalidateRect(GetDlgItem(hwnd, ID_HALT), NULL, TRUE);
+ (void)state;
+}
+
+static void UpdateHaltButton(HWND hwnd, APPSTATE *state)
+{
+ char label[256];
+
+ if (state->activeStop >= state->stopCount) {
+ SetWindowTextA(GetDlgItem(hwnd, ID_HALT), "ROUTE COMPLETE");
+ SetStatusText(hwnd, "Route complete");
+ return;
+ }
+
+ snprintf(label, sizeof(label), "%s\r\n+%s min", state->stopNames[state->activeStop], state->stopTimes[state->activeStop]);
+ SetWindowTextA(GetDlgItem(hwnd, ID_HALT), label);
+ SetStatusText(hwnd, "Route in progress");
+}
+
+static void ShowLoginScreen(HWND hwnd)
+{
+ SetWindowTextA(hwnd, "Transit Driver XP - Driver Login");
+ SetWindowTextA(GetDlgItem(hwnd, ID_PAGE_TITLE), "DRIVER LOGIN");
+ ShowControl(hwnd, ID_DATE, 0);
+ ShowControl(hwnd, ID_DATE_LABEL, 0);
+ ShowControl(hwnd, ID_ROUTES, 0);
+ ShowControl(hwnd, ID_ROUTES_LABEL, 0);
+ ShowControl(hwnd, ID_REFRESH, 0);
+ ShowControl(hwnd, ID_STOPS, 0);
+ ShowControl(hwnd, ID_OUTPUT, 1);
+ ShowControl(hwnd, ID_BACK, 0);
+ ShowControl(hwnd, ID_HALT, 0);
+ ShowControl(hwnd, ID_USERNAME, 1);
+ ShowControl(hwnd, ID_PASSWORD, 1);
+ ShowControl(hwnd, ID_USER_LABEL, 1);
+ ShowControl(hwnd, ID_PASSWORD_LABEL, 1);
+ ShowControl(hwnd, ID_LOGIN, 1);
+}
+
+static void DrawHaltButton(const DRAWITEMSTRUCT *draw)
+{
+ char label[256];
+ HBRUSH brush = CreateSolidBrush(RGB(210, 55, 45));
+ HPEN pen = CreatePen(PS_SOLID, 2, RGB(120, 25, 20));
+ HGDIOBJ oldBrush = SelectObject(draw->hDC, brush);
+ HGDIOBJ oldPen = SelectObject(draw->hDC, pen);
+ RECT rect = draw->rcItem;
+
+ Ellipse(draw->hDC, rect.left + 4, rect.top + 4, rect.right - 4, rect.bottom - 4);
+ SetBkMode(draw->hDC, TRANSPARENT);
+ SetTextColor(draw->hDC, RGB(255, 255, 255));
+ GetWindowTextA(draw->hwndItem, label, sizeof(label));
+ DrawTextA(draw->hDC, label, -1, &rect, DT_CENTER | DT_VCENTER | DT_WORDBREAK);
+
+ SelectObject(draw->hDC, oldBrush);
+ SelectObject(draw->hDC, oldPen);
+ DeleteObject(brush);
+ DeleteObject(pen);
+}
+
+static void PerformRefresh(HWND hwnd, APPSTATE *state);
+
+static void TrimNewlines(char *text)
+{
+ size_t len = strlen(text);
+ while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r' || text[len - 1] == ' ')) {
+ text[len - 1] = '\0';
+ len--;
+ }
+}
+
+static void ExtractJsonFieldString(const char *json,
+ const char *field,
+ char *out,
+ size_t outSize)
+{
+ char pattern[128];
+ const char *key;
+ const char *colon;
+ const char *value;
+ const char *end;
+
+ out[0] = '\0';
+ if (!json || !field || outSize == 0) {
+ return;
+ }
+
+ snprintf(pattern, sizeof(pattern), "\"%s\"", field);
+ key = strstr(json, pattern);
+ if (!key) {
+ return;
+ }
+
+ colon = strchr(key + strlen(pattern), ':');
+ if (!colon) {
+ return;
+ }
+
+ value = colon + 1;
+ while (*value == ' ' || *value == '\t' || *value == '\n' || *value == '\r') {
+ value++;
+ }
+
+ if (*value == '"') {
+ const char *q1 = value + 1;
+ const char *q2 = strchr(q1, '"');
+ size_t len;
+
+ if (!q2) {
+ return;
+ }
+
+ len = (size_t)(q2 - q1);
+ if (len >= outSize) {
+ len = outSize - 1;
+ }
+ memcpy(out, q1, len);
+ out[len] = '\0';
+ return;
+ }
+
+ end = value;
+ while (*end && *end != ',' && *end != '}' && *end != '\n' && *end != '\r') {
+ end++;
+ }
+
+ if (end == value) {
+ return;
+ }
+
+ size_t len = (size_t)(end - value);
+ while (len > 0 && (value[len - 1] == ' ' || value[len - 1] == '\t' || value[len - 1] == '\r' || value[len - 1] == '\n')) {
+ len--;
+ }
+
+ if (len >= outSize) {
+ len = outSize - 1;
+ }
+
+ memcpy(out, value, len);
+ out[len] = '\0';
+}
+
+static char *FindObjectEnd(char *start)
+{
+ int depth = 0;
+ char *p = start;
+
+ while (*p) {
+ if (*p == '{') {
+ depth++;
+ } else if (*p == '}') {
+ depth--;
+ if (depth == 0) {
+ return p;
+ }
+ }
+ p++;
+ }
+
+ return NULL;
+}
+
+static void ExtractScheduleSummary(const char *json, APPSTATE *state, char *out, size_t outSize)
+{
+ const char *routesKey;
+ const char *p;
+ char routeSummary[2048];
+ char summary[8192];
+ int routeCount = 0;
+
+ out[0] = '\0';
+ summary[0] = '\0';
+ routeSummary[0] = '\0';
+ state->routeCount = 0;
+ SendMessage(GetDlgItem(state->hwnd, ID_ROUTES), LB_RESETCONTENT, 0, 0);
+
+ routesKey = strstr(json, "\"routes\"");
+ if (!routesKey) {
+ snprintf(out, outSize, "No routes found for this date.");
+ return;
+ }
+
+ p = strchr(routesKey, '[');
+ if (!p) {
+ snprintf(out, outSize, "No routes found for this date.");
+ return;
+ }
+
+ p++;
+ while (*p) {
+ char *objStart = strchr((char *)p, '{');
+ char *objEnd;
+ char routeText[2048];
+ char routeLine[128];
+ char routeStatus[128];
+ char routeStart[128];
+ char routeEnd[128];
+ char routeStartStop[128];
+ char routeEndStop[128];
+ char routeLineId[32];
+ char routeStartTime[128];
+
+ if (!objStart) {
+ break;
+ }
+
+ objEnd = FindObjectEnd(objStart);
+ if (!objEnd) {
+ break;
+ }
+
+ if ((size_t)(objEnd - objStart + 1) >= sizeof(routeText)) {
+ break;
+ }
+
+ memcpy(routeText, objStart, objEnd - objStart + 1);
+ routeText[objEnd - objStart + 1] = '\0';
+
+ routeLine[0] = '\0';
+ routeStatus[0] = '\0';
+ routeStart[0] = '\0';
+ routeEnd[0] = '\0';
+ routeStartStop[0] = '\0';
+ routeEndStop[0] = '\0';
+ routeLineId[0] = '\0';
+ routeStartTime[0] = '\0';
+
+ ExtractJsonFieldString(routeText, "lineNumber", routeLine, sizeof(routeLine));
+ ExtractJsonFieldString(routeText, "startTime", routeStartTime, sizeof(routeStartTime));
+ ExtractJsonFieldString(routeText, "id", routeLineId, sizeof(routeLineId));
+ {
+ char busLine[1024];
+ const char *busKey = strstr(routeText, "\"busLine\"");
+ if (busKey) {
+ const char *busStart = strchr(busKey, '{');
+ if (busStart) {
+ char *busEnd = FindObjectEnd((char *)busStart);
+ if (busEnd) {
+ size_t busLen = (size_t)(busEnd - busStart + 1);
+ if (busLen < sizeof(busLine)) {
+ memcpy(busLine, busStart, busLen);
+ busLine[busLen] = '\0';
+ ExtractJsonFieldString(busLine, "lineNumber", routeLine, sizeof(routeLine));
+ ExtractJsonFieldString(busLine, "id", routeLineId, sizeof(routeLineId));
+ ExtractJsonFieldString(busLine, "startStop", routeStartStop, sizeof(routeStartStop));
+ ExtractJsonFieldString(busLine, "endStop", routeEndStop, sizeof(routeEndStop));
+ }
+ }
+ }
+ }
+ }
+
+ ExtractJsonFieldString(routeText, "status", routeStatus, sizeof(routeStatus));
+ ExtractJsonFieldString(routeText, "startTime", routeStart, sizeof(routeStart));
+ ExtractJsonFieldString(routeText, "endTime", routeEnd, sizeof(routeEnd));
+ if (routeStartStop[0] == '\0') {
+ ExtractJsonFieldString(routeText, "startStop", routeStartStop, sizeof(routeStartStop));
+ }
+ if (routeEndStop[0] == '\0') {
+ ExtractJsonFieldString(routeText, "endStop", routeEndStop, sizeof(routeEndStop));
+ }
+
+ if (routeLine[0] == '\0') {
+ snprintf(routeLine, sizeof(routeLine), "R%d", routeCount + 1);
+ }
+ if (routeStatus[0] == '\0') {
+ snprintf(routeStatus, sizeof(routeStatus), "planned");
+ }
+ if (routeStartStop[0] == '\0') {
+ snprintf(routeStartStop, sizeof(routeStartStop), "N/A");
+ }
+ if (routeEndStop[0] == '\0') {
+ snprintf(routeEndStop, sizeof(routeEndStop), "N/A");
+ }
+
+ routeCount++;
+ if (state->routeCount < 32) {
+ state->routeLineIds[state->routeCount] = atoi(routeLineId);
+ snprintf(state->routeLabels[state->routeCount], sizeof(state->routeLabels[state->routeCount]),
+ "Line %s %s %s", routeLine, routeStart, routeStatus);
+ SendMessageA(GetDlgItem(state->hwnd, ID_ROUTES), LB_ADDSTRING, 0,
+ (LPARAM)state->routeLabels[state->routeCount]);
+ state->routeCount++;
+ }
+ snprintf(routeSummary + strlen(routeSummary), sizeof(routeSummary) - strlen(routeSummary),
+ "Route %s: %s -> %s | %s | %s\r\n",
+ routeLine,
+ routeStartStop,
+ routeEndStop,
+ routeStart,
+ routeStatus);
+
+ p = objEnd + 1;
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',') {
+ p++;
+ }
+
+ if (*p == ']' || *p == '\0') {
+ break;
+ }
+ }
+
+ if (routeCount == 0) {
+ snprintf(out, outSize, "No routes found for this date.");
+ return;
+ }
+
+ snprintf(summary, sizeof(summary), "Daily schedule (%d route%s)\r\n\r\n%s",
+ routeCount, routeCount == 1 ? "" : "s",
+ routeSummary);
+
+ TrimNewlines(summary);
+ snprintf(out, outSize, "%s", summary);
+}
+
+static void ExtractStopsSummary(const char *json, APPSTATE *state, char *out, size_t outSize)
+{
+ const char *stopsKey = strstr(json, "\"stops\"");
+ const char *p;
+ char summary[4096];
+ int count = 0;
+
+ summary[0] = '\0';
+ if (!stopsKey || !(p = strchr(stopsKey, '['))) {
+ snprintf(out, outSize, "No stop details were returned for this route.");
+ return;
+ }
+
+ p++;
+ while (*p && *p != ']') {
+ char *start = strchr((char *)p, '{');
+ char *end;
+ char item[1024];
+ char order[32];
+ char name[128];
+ char eta[32];
+
+ if (!start || !(end = FindObjectEnd(start))) {
+ break;
+ }
+ if ((size_t)(end - start + 1) >= sizeof(item)) {
+ break;
+ }
+
+ memcpy(item, start, end - start + 1);
+ item[end - start + 1] = '\0';
+ ExtractJsonFieldString(item, "order", order, sizeof(order));
+ ExtractJsonFieldString(item, "name", name, sizeof(name));
+ ExtractJsonFieldString(item, "estimatedArrivalMinutes", eta, sizeof(eta));
+ if (name[0] == '\0') {
+ snprintf(name, sizeof(name), "Unnamed stop");
+ }
+ if (eta[0] == '\0') {
+ snprintf(eta, sizeof(eta), "-");
+ }
+
+ snprintf(summary + strlen(summary), sizeof(summary) - strlen(summary),
+ "%s. %s (arrival +%s min)\r\n", order, name, eta);
+ if (count < 64) {
+ snprintf(state->stopNames[count], sizeof(state->stopNames[count]), "%s", name);
+ snprintf(state->stopTimes[count], sizeof(state->stopTimes[count]), "%s", eta);
+ }
+ count++;
+ p = end + 1;
+ while (*p == ' ' || *p == '\r' || *p == '\n' || *p == '\t' || *p == ',') {
+ p++;
+ }
+ }
+
+ if (count == 0) {
+ snprintf(out, outSize, "No stop details were returned for this route.");
+ return;
+ }
+
+ state->stopCount = count < 64 ? count : 64;
+ state->activeStop = 0;
+ snprintf(out, outSize, "Stop-by-stop timetable (%d stops)\r\n\r\n%s", count, summary);
+}
+
+static void ExtractTokenFromJson(const char *json, char *outToken, size_t outSize)
+{
+ const char *tokenKey = strstr(json, "\"token\"");
+ const char *q1;
+ const char *q2;
+
+ outToken[0] = '\0';
+ if (!tokenKey) {
+ return;
+ }
+
+ q1 = strchr(tokenKey, ':');
+ if (!q1) {
+ return;
+ }
+ q1 = strchr(q1 + 1, '"');
+ if (!q1) {
+ return;
+ }
+ q2 = strchr(q1 + 1, '"');
+ if (!q2) {
+ return;
+ }
+
+ q1++;
+ if ((size_t)(q2 - q1) >= outSize) {
+ strncpy(outToken, q1, outSize - 1);
+ outToken[outSize - 1] = '\0';
+ return;
+ }
+
+ memcpy(outToken, q1, q2 - q1);
+ outToken[q2 - q1] = '\0';
+}
+
+static void HttpErrorMessage(const char *context, DWORD error)
+{
+ (void)context;
+ (void)error;
+}
+
+static int HttpRequestText(const char *method,
+ const char *url,
+ const char *body,
+ const char *authHeader,
+ char *response,
+ size_t responseSize)
+{
+ HINTERNET hSession = NULL;
+ HINTERNET hConnect = NULL;
+ HINTERNET hRequest = NULL;
+ wchar_t hostW[256];
+ wchar_t pathW[512];
+ wchar_t methodW[32];
+ char host[256];
+ char path[512];
+ const char *src;
+ const char *slash;
+ const char *colon;
+ int port = 80;
+ DWORD flags = 0;
+ DWORD status = 0;
+ DWORD statusLen = sizeof(status);
+ DWORD bytesRead = 0;
+ DWORD totalRead = 0;
+ char buffer[MAX_BUF];
+ int ok = 0;
+
+ response[0] = '\0';
+
+ src = url;
+ if (strncmp(src, "http://", 7) == 0) {
+ src += 7;
+ } else if (strncmp(src, "https://", 8) == 0) {
+ src += 8;
+ }
+
+ host[0] = '\0';
+ path[0] = '/';
+ path[1] = '\0';
+
+ slash = strchr(src, '/');
+ colon = strchr(src, ':');
+
+ if (slash) {
+ size_t hostLen = (size_t)(slash - src);
+ if (hostLen >= sizeof(host)) {
+ hostLen = sizeof(host) - 1;
+ }
+ memcpy(host, src, hostLen);
+ host[hostLen] = '\0';
+ snprintf(path, sizeof(path), "%.*s", (int)(strlen(slash)), slash);
+ } else if (colon) {
+ size_t hostLen = (size_t)(colon - src);
+ if (hostLen >= sizeof(host)) {
+ hostLen = sizeof(host) - 1;
+ }
+ memcpy(host, src, hostLen);
+ host[hostLen] = '\0';
+ port = atoi(colon + 1);
+ snprintf(path, sizeof(path), "/");
+ } else {
+ snprintf(host, sizeof(host), "%s", src);
+ snprintf(path, sizeof(path), "/");
+ }
+
+ if (colon && slash) {
+ size_t hostLen = (size_t)(colon - src);
+ if (hostLen >= sizeof(host)) {
+ hostLen = sizeof(host) - 1;
+ }
+ memcpy(host, src, hostLen);
+ host[hostLen] = '\0';
+ port = atoi(colon + 1);
+ }
+
+ if (host[0] == '\0') {
+ snprintf(host, sizeof(host), "192.168.68.66");
+ }
+
+ if (port == 0) {
+ port = 80;
+ }
+
+ MultiByteToWideChar(CP_UTF8, 0, host, -1, hostW, 256);
+ MultiByteToWideChar(CP_UTF8, 0, path, -1, pathW, 512);
+ MultiByteToWideChar(CP_UTF8, 0, method, -1, methodW, 32);
+
+ hSession = WinHttpOpen(L"TransitDriverXP/1.0",
+ WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
+ WINHTTP_NO_PROXY_NAME,
+ WINHTTP_NO_PROXY_BYPASS,
+ 0);
+ if (!hSession) {
+ HttpErrorMessage("Unable to open WinHTTP session", GetLastError());
+ return 0;
+ }
+
+ hConnect = WinHttpConnect(hSession, hostW, port, 0);
+ if (!hConnect) {
+ char hostText[256];
+ char errorText[512];
+ WideCharToMultiByte(CP_UTF8, 0, hostW, -1, hostText, sizeof(hostText), NULL, NULL);
+ snprintf(errorText, sizeof(errorText), "Unable to connect to %s:%d", hostText, port);
+ HttpErrorMessage(errorText, GetLastError());
+ goto cleanup;
+ }
+
+ hRequest = WinHttpOpenRequest(hConnect,
+ methodW,
+ pathW,
+ NULL,
+ WINHTTP_NO_REFERER,
+ WINHTTP_DEFAULT_ACCEPT_TYPES,
+ 0);
+ if (!hRequest) {
+ HttpErrorMessage("Unable to open request", GetLastError());
+ goto cleanup;
+ }
+
+ if (body != NULL && body[0] != '\0') {
+ if (!WinHttpAddRequestHeaders(hRequest,
+ L"Content-Type: application/json\r\n",
+ -1,
+ WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)) {
+ HttpErrorMessage("Unable to add JSON content type", GetLastError());
+ goto cleanup;
+ }
+ }
+
+ if (authHeader && authHeader[0] != '\0') {
+ wchar_t wHeader[512];
+ MultiByteToWideChar(CP_UTF8, 0, authHeader, -1, wHeader, 512);
+ if (!WinHttpAddRequestHeaders(hRequest,
+ wHeader,
+ -1,
+ WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)) {
+ HttpErrorMessage("Unable to add authorization header", GetLastError());
+ goto cleanup;
+ }
+ }
+
+ if (!WinHttpSendRequest(hRequest,
+ WINHTTP_NO_ADDITIONAL_HEADERS,
+ 0,
+ (LPVOID)body,
+ body ? (DWORD)strlen(body) : 0,
+ body ? (DWORD)strlen(body) : 0,
+ 0)) {
+ HttpErrorMessage("WinHTTP send request failed", GetLastError());
+ goto cleanup;
+ }
+
+ if (!WinHttpReceiveResponse(hRequest, NULL)) {
+ HttpErrorMessage("WinHTTP receive response failed", GetLastError());
+ goto cleanup;
+ }
+
+ if (!WinHttpQueryHeaders(hRequest,
+ WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
+ WINHTTP_HEADER_NAME_BY_INDEX,
+ &status,
+ &statusLen,
+ WINHTTP_NO_HEADER_INDEX)) {
+ HttpErrorMessage("Unable to query HTTP status", GetLastError());
+ goto cleanup;
+ }
+
+ if (status >= 400) {
+ snprintf(response, responseSize, "HTTP error %lu", status);
+ ok = 0;
+ goto cleanup;
+ }
+
+ while (WinHttpQueryDataAvailable(hRequest, &bytesRead) && bytesRead > 0) {
+ DWORD actual = 0;
+ if (!WinHttpReadData(hRequest, buffer, sizeof(buffer) - 1, &actual)) {
+ HttpErrorMessage("Unable to read response data", GetLastError());
+ goto cleanup;
+ }
+ if (actual == 0) {
+ break;
+ }
+ buffer[actual] = '\0';
+ if (totalRead + actual + 1 < responseSize) {
+ memcpy(response + totalRead, buffer, actual);
+ totalRead += actual;
+ response[totalRead] = '\0';
+ }
+ }
+
+ ok = 1;
+
+cleanup:
+ if (hRequest) {
+ WinHttpCloseHandle(hRequest);
+ }
+ if (hConnect) {
+ WinHttpCloseHandle(hConnect);
+ }
+ if (hSession) {
+ WinHttpCloseHandle(hSession);
+ }
+
+ return ok;
+}
+
+static void PerformLogin(HWND hwnd, APPSTATE *state)
+{
+ char userCode[128];
+ char password[128];
+ char payload[512];
+ char response[MAX_BUF];
+ char token[256];
+
+ GetWindowTextA(GetDlgItem(hwnd, ID_USERNAME), userCode, sizeof(userCode));
+ GetWindowTextA(GetDlgItem(hwnd, ID_PASSWORD), password, sizeof(password));
+
+ if (userCode[0] == '\0' || password[0] == '\0') {
+ SetStatusText(hwnd, "Enter user code and password.");
+ return;
+ }
+
+ snprintf(payload, sizeof(payload),
+ "{\"userCode\":\"%s\",\"password\":\"%s\"}",
+ userCode, password);
+
+ SetStatusText(hwnd, "Logging in...");
+ if (!HttpRequestText("POST", "http://192.168.68.66:5000/login", payload, NULL, response, sizeof(response))) {
+ SetOutputText(hwnd, "Driver service unreachable. Please try again.");
+ SetStatusText(hwnd, "Login failed");
+ return;
+ }
+
+ ExtractTokenFromJson(response, token, sizeof(token));
+ if (token[0] == '\0') {
+ SetOutputText(hwnd, "Driver service unreachable. Please try again.");
+ SetStatusText(hwnd, "Service unavailable");
+ return;
+ }
+
+ strncpy(state->token, token, sizeof(state->token) - 1);
+ state->token[sizeof(state->token) - 1] = '\0';
+ ShowRouteScreen(hwnd);
+ SetStatusText(hwnd, "Loading today's assigned routes...");
+ PerformRefresh(hwnd, state);
+}
+
+static void PerformRefresh(HWND hwnd, APPSTATE *state)
+{
+ char response[MAX_BUF];
+ char authHeader[512];
+ char date[32];
+ char scheduleUrl[256];
+
+ if (state->token[0] == '\0') {
+ SetStatusText(hwnd, "Login first.");
+ return;
+ }
+
+ snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token);
+ GetWindowTextA(GetDlgItem(hwnd, ID_DATE), date, sizeof(date));
+ if (date[0] == '\0') {
+ snprintf(date, sizeof(date), "2026-09-17");
+ }
+ snprintf(scheduleUrl, sizeof(scheduleUrl), "http://192.168.68.66:5001/driver/schedules/daily?date=%s", date);
+ SetStatusText(hwnd, "Loading schedule...");
+
+ if (!HttpRequestText("GET", scheduleUrl, NULL, authHeader, response, sizeof(response))) {
+ SetOutputText(hwnd, "Driver service unreachable. Please try again.");
+ SetStatusText(hwnd, "Schedule load failed");
+ return;
+ }
+
+ ExtractScheduleSummary(response, state, state->lastText, sizeof(state->lastText));
+ SetOutputText(hwnd, state->lastText);
+ SetStatusText(hwnd, "Schedule loaded");
+}
+
+static void PerformLoadStops(HWND hwnd, APPSTATE *state)
+{
+ char response[MAX_BUF];
+ char authHeader[512];
+ char stopsUrl[256];
+ LRESULT selected;
+ int lineId;
+
+ if (state->token[0] == '\0') {
+ SetStatusText(hwnd, "Login first.");
+ return;
+ }
+
+ selected = SendMessage(GetDlgItem(hwnd, ID_ROUTES), LB_GETCURSEL, 0, 0);
+ if (selected == LB_ERR || selected >= state->routeCount) {
+ SetStatusText(hwnd, "Select a route first.");
+ return;
+ }
+
+ lineId = state->routeLineIds[selected];
+ if (lineId <= 0) {
+ SetOutputText(hwnd, "This schedule does not contain a valid bus line ID.");
+ SetStatusText(hwnd, "Stop details unavailable");
+ return;
+ }
+
+ snprintf(authHeader, sizeof(authHeader), "Authorization: Bearer %s\r\n", state->token);
+ snprintf(stopsUrl, sizeof(stopsUrl), "http://192.168.68.66:5001/bus-lines/%d/stops", lineId);
+ SetStatusText(hwnd, "Loading stop details...");
+
+ if (!HttpRequestText("GET", stopsUrl, NULL, authHeader, response, sizeof(response))) {
+ SetOutputText(hwnd, "Driver service unreachable. Please try again.");
+ SetStatusText(hwnd, "Stop details failed");
+ return;
+ }
+
+ ExtractStopsSummary(response, state, state->lastText, sizeof(state->lastText));
+ ShowStartScreen(hwnd, state);
+ UpdateHaltButton(hwnd, state);
+}
+
+static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
+{
+ static APPSTATE *state = NULL;
+ HWND hUser, hPassword, hLogin, hRefresh, hOutput, hStatus;
+
+ switch (msg) {
+ case WM_CREATE:
+ {
+ LPCREATESTRUCT cs = (LPCREATESTRUCT)lParam;
+ SYSTEMTIME now;
+ char today[16];
+ state = (APPSTATE *)calloc(1, sizeof(APPSTATE));
+ state->hwnd = hwnd;
+ cs->lpCreateParams = state;
+ SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)state);
+ GetLocalTime(&now);
+ snprintf(today, sizeof(today), "%04d-%02d-%02d", now.wYear, now.wMonth, now.wDay);
+
+ CreateWindowExA(0, "STATIC", "DRIVER LOGIN",
+ WS_CHILD | WS_VISIBLE,
+ 20, 10, 330, 24,
+ hwnd, (HMENU)ID_PAGE_TITLE, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "STATIC", "Schedule date (YYYY-MM-DD)",
+ WS_CHILD | WS_VISIBLE,
+ 20, 38, 150, 20,
+ hwnd, (HMENU)ID_DATE_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "EDIT", today,
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL,
+ 175, 36, 130, 24,
+ hwnd, (HMENU)ID_DATE, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "STATIC", "User code",
+ WS_CHILD | WS_VISIBLE,
+ 20, 72, 90, 20,
+ hwnd, (HMENU)ID_USER_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hUser = CreateWindowExA(0, "EDIT", "",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL,
+ 120, 70, 180, 24,
+ hwnd, (HMENU)ID_USERNAME, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+ SetFocus(hUser);
+
+ CreateWindowExA(0, "STATIC", "Password",
+ WS_CHILD | WS_VISIBLE,
+ 20, 104, 90, 20,
+ hwnd, (HMENU)ID_PASSWORD_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hPassword = CreateWindowExA(0, "EDIT", "",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_PASSWORD | ES_AUTOHSCROLL,
+ 120, 102, 180, 24,
+ hwnd, (HMENU)ID_PASSWORD, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hLogin = CreateWindowExA(0, "BUTTON", "Login",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP,
+ 120, 138, 90, 30,
+ hwnd, (HMENU)ID_LOGIN, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hRefresh = CreateWindowExA(0, "BUTTON", "Refresh schedule",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP,
+ 220, 138, 130, 30,
+ hwnd, (HMENU)ID_REFRESH, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hStatus = CreateWindowExA(0, "STATIC", "Idle",
+ WS_CHILD | WS_VISIBLE,
+ 20, 178, 500, 22,
+ hwnd, (HMENU)ID_STATUS, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "STATIC", "TODAY'S ROUTES",
+ WS_CHILD | WS_VISIBLE,
+ 20, 208, 200, 20,
+ hwnd, (HMENU)ID_ROUTES_LABEL, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(WS_EX_CLIENTEDGE, "LISTBOX", "",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | LBS_NOTIFY | WS_VSCROLL,
+ 20, 230, 500, 105,
+ hwnd, (HMENU)ID_ROUTES, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ hOutput = CreateWindowExA(WS_EX_CLIENTEDGE, "EDIT", "",
+ WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_READONLY,
+ 20, 345, 500, 190,
+ hwnd, (HMENU)ID_OUTPUT, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "BUTTON", "Show selected route stops",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP,
+ 20, 545, 190, 30,
+ hwnd, (HMENU)ID_STOPS, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "BUTTON", "HALT",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW,
+ 145, 120, 250, 250,
+ hwnd, (HMENU)ID_HALT, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ CreateWindowExA(0, "BUTTON", "Back to today's routes",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP,
+ 20, 545, 190, 30,
+ hwnd, (HMENU)ID_BACK, ((LPCREATESTRUCT)lParam)->hInstance, NULL);
+
+ SetWindowTextA(hOutput, "Driver console ready.\r\nLog in, choose a date, and refresh the schedule.");
+ ShowLoginScreen(hwnd);
+ return 0;
+ }
+
+ case WM_COMMAND:
+ if (LOWORD(wParam) == ID_LOGIN && HIWORD(wParam) == BN_CLICKED) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ PerformLogin(hwnd, state);
+ return 0;
+ }
+
+ if (LOWORD(wParam) == ID_REFRESH && HIWORD(wParam) == BN_CLICKED) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ PerformRefresh(hwnd, state);
+ return 0;
+ }
+
+ if (LOWORD(wParam) == ID_STOPS && HIWORD(wParam) == BN_CLICKED) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ PerformLoadStops(hwnd, state);
+ return 0;
+ }
+
+ if (LOWORD(wParam) == ID_BACK && HIWORD(wParam) == BN_CLICKED) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ ShowRouteScreen(hwnd);
+ SetStatusText(hwnd, "Today's routes");
+ return 0;
+ }
+
+ if (LOWORD(wParam) == ID_HALT && HIWORD(wParam) == BN_CLICKED) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ if (state->activeStop < state->stopCount) {
+ state->activeStop++;
+ UpdateHaltButton(hwnd, state);
+ }
+ return 0;
+ }
+
+ if (LOWORD(wParam) == ID_ROUTES && HIWORD(wParam) == LBN_SELCHANGE) {
+ state = (APPSTATE *)GetWindowLongPtr(hwnd, GWLP_USERDATA);
+ PerformLoadStops(hwnd, state);
+ return 0;
+ }
+ break;
+
+ case WM_DRAWITEM:
+ if (wParam == ID_HALT) {
+ DrawHaltButton((DRAWITEMSTRUCT *)lParam);
+ return TRUE;
+ }
+ break;
+
+ case WM_DESTROY:
+ PostQuitMessage(0);
+ return 0;
+ }
+
+ return DefWindowProc(hwnd, msg, wParam, lParam);
+}
+
+int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
+{
+ WNDCLASSA wc;
+ HWND hwnd;
+ MSG msg;
+
+ memset(&wc, 0, sizeof(wc));
+ wc.lpfnWndProc = WndProc;
+ wc.hInstance = hInstance;
+ wc.hbrBackground = (HBRUSH)(COLOR_3DFACE + 1);
+ wc.lpszClassName = "TransitDriverXPClass";
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW);
+ wc.hIcon = LoadIcon(NULL, IDI_APPLICATION);
+ wc.style = CS_HREDRAW | CS_VREDRAW;
+
+ if (!RegisterClassA(&wc)) {
+ MessageBoxA(NULL, "Failed to register window class.", "XP Driver App", MB_ICONERROR | MB_OK);
+ return 1;
+ }
+
+ hwnd = CreateWindowExA(0,
+ "TransitDriverXPClass",
+ "Transit Driver XP",
+ WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,
+ CW_USEDEFAULT,
+ CW_USEDEFAULT,
+ 560,
+ 640,
+ NULL,
+ NULL,
+ hInstance,
+ NULL);
+
+ if (!hwnd) {
+ MessageBoxA(NULL, "Failed to create window.", "XP Driver App", MB_ICONERROR | MB_OK);
+ return 1;
+ }
+
+ ShowWindow(hwnd, nCmdShow);
+ UpdateWindow(hwnd);
+
+ while (GetMessage(&msg, NULL, 0, 0)) {
+ TranslateMessage(&msg);
+ DispatchMessage(&msg);
+ }
+
+ return (int)msg.wParam;
+}
diff --git a/windows_xp_driver_ui/driver_xp.exe b/windows_xp_driver_ui/driver_xp.exe
new file mode 100755
index 0000000..f433101
Binary files /dev/null and b/windows_xp_driver_ui/driver_xp.exe differ
diff --git a/windows_xp_driver_ui/installer.nsi b/windows_xp_driver_ui/installer.nsi
new file mode 100644
index 0000000..3dac844
--- /dev/null
+++ b/windows_xp_driver_ui/installer.nsi
@@ -0,0 +1,57 @@
+!include "MUI2.nsh"
+
+!define APPNAME "Transit Driver XP"
+!define COMPANYNAME "Transit Project"
+!define VERSION "2.0.0"
+
+Name "${APPNAME}"
+OutFile "TransitDriverXPSetup.exe"
+InstallDir "$PROGRAMFILES\TransitDriverXP"
+InstallDirRegKey HKCU "Software\TransitDriverXP" "InstallDir"
+RequestExecutionLevel admin
+VIProductVersion "2.0.0.0"
+VIAddVersionKey "ProductName" "${APPNAME}"
+VIAddVersionKey "CompanyName" "${COMPANYNAME}"
+VIAddVersionKey "FileDescription" "Native Windows XP driver client"
+VIAddVersionKey "FileVersion" "${VERSION}"
+VIAddVersionKey "ProductVersion" "${VERSION}"
+VIAddVersionKey "LegalCopyright" "Transit Project"
+
+Function .onInit
+ ReadRegStr $0 HKCU "Software\TransitDriverXP" "InstallDir"
+ StrCmp $0 "" done
+ IfFileExists "$0\driver_xp.exe" 0 done
+ StrCpy $INSTDIR $0
+ MessageBox MB_OK|MB_ICONINFORMATION "An existing Transit Driver XP installation was found. It will be updated."
+ ExecWait '"$SYSDIR\taskkill.exe" /F /IM driver_xp.exe'
+done:
+FunctionEnd
+
+!insertmacro MUI_PAGE_DIRECTORY
+!insertmacro MUI_PAGE_INSTFILES
+!insertmacro MUI_LANGUAGE "English"
+
+Section "Install"
+ SetOutPath "$INSTDIR"
+ SetOverwrite on
+ File "driver_xp.exe"
+ WriteRegStr HKCU "Software\TransitDriverXP" "InstallDir" "$INSTDIR"
+ WriteRegStr HKCU "Software\TransitDriverXP" "DisplayName" "${APPNAME}"
+ WriteRegStr HKCU "Software\TransitDriverXP" "Version" "${VERSION}"
+ CreateDirectory "$SMPROGRAMS\TransitDriverXP"
+ CreateShortCut "$SMPROGRAMS\TransitDriverXP\Transit Driver XP.lnk" "$INSTDIR\driver_xp.exe"
+ CreateShortCut "$DESKTOP\Transit Driver XP.lnk" "$INSTDIR\driver_xp.exe"
+ WriteUninstaller "$INSTDIR\Uninstall.exe"
+ CreateShortCut "$SMPROGRAMS\TransitDriverXP\Uninstall Transit Driver XP.lnk" "$INSTDIR\Uninstall.exe"
+SectionEnd
+
+Section "Uninstall"
+ Delete "$DESKTOP\Transit Driver XP.lnk"
+ Delete "$SMPROGRAMS\TransitDriverXP\Transit Driver XP.lnk"
+ Delete "$SMPROGRAMS\TransitDriverXP\Uninstall Transit Driver XP.lnk"
+ RMDir "$SMPROGRAMS\TransitDriverXP"
+ Delete "$INSTDIR\driver_xp.exe"
+ Delete "$INSTDIR\Uninstall.exe"
+ RMDir "$INSTDIR"
+ DeleteRegKey HKCU "Software\TransitDriverXP"
+SectionEnd