feat: Initial commit - Train tracking system
Some checks failed
Auto Tag on Merge to Main / auto-tag (push) Successful in 27s
CI - Lint and Build / lint-backend (push) Failing after 30s
CI - Lint and Build / lint-frontend (push) Failing after 2s
CI - Lint and Build / build-frontend (push) Has been skipped
CI - Lint and Build / docker-build-test (push) Has been skipped

Complete real-time train tracking system for Spanish railways (Renfe/Cercanías):

- Backend API (Node.js/Express) with GTFS-RT polling workers
- Frontend dashboard (React/Vite) with Leaflet maps
- Real-time updates via Socket.io WebSocket
- PostgreSQL/PostGIS database with Flyway migrations
- Redis caching layer
- Docker Compose configuration for development and production
- Gitea CI/CD workflows (lint, auto-tag, release)
- Production deployment with nginx + Let's Encrypt SSL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Millaguie
2025-11-28 00:21:15 +01:00
commit 34c0cb50c7
64 changed files with 15577 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { useState, useEffect, useCallback, useRef } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export function useDashboard() {
const [isLive, setIsLive] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [stats, setStats] = useState(null);
const [timeline, setTimeline] = useState([]);
const [linesRanking, setLinesRanking] = useState([]);
const [availableRange, setAvailableRange] = useState({ earliest: null, latest: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const refreshIntervalRef = useRef(null);
// Fetch available data range
const fetchAvailableRange = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/dashboard/available-range`);
if (!response.ok) throw new Error('Failed to fetch available range');
const data = await response.json();
setAvailableRange({
earliest: data.earliest ? new Date(data.earliest) : null,
latest: data.latest ? new Date(data.latest) : null,
});
} catch (err) {
console.error('Error fetching available range:', err);
}
}, []);
// Fetch current/live stats
const fetchCurrentStats = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/dashboard/current`);
if (!response.ok) throw new Error('Failed to fetch current stats');
const data = await response.json();
setStats(data);
setCurrentTime(new Date(data.timestamp));
setError(null);
} catch (err) {
console.error('Error fetching current stats:', err);
setError(err.message);
}
}, []);
// Fetch snapshot at specific time
const fetchSnapshotStats = useCallback(async (timestamp) => {
try {
setIsLoading(true);
const response = await fetch(`${API_URL}/dashboard/snapshot?timestamp=${timestamp.toISOString()}`);
if (!response.ok) throw new Error('Failed to fetch snapshot stats');
const data = await response.json();
setStats(data);
setError(null);
} catch (err) {
console.error('Error fetching snapshot stats:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
}, []);
// Fetch timeline data
const fetchTimeline = useCallback(async (start, end, interval = 5) => {
try {
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
interval: interval.toString(),
});
const response = await fetch(`${API_URL}/dashboard/timeline?${params}`);
if (!response.ok) throw new Error('Failed to fetch timeline');
const data = await response.json();
setTimeline(data.data);
} catch (err) {
console.error('Error fetching timeline:', err);
}
}, []);
// Fetch lines ranking
const fetchLinesRanking = useCallback(async (timestamp, hours = 24) => {
try {
const params = new URLSearchParams({
timestamp: timestamp.toISOString(),
hours: hours.toString(),
});
const response = await fetch(`${API_URL}/dashboard/lines-ranking?${params}`);
if (!response.ok) throw new Error('Failed to fetch lines ranking');
const data = await response.json();
setLinesRanking(data);
} catch (err) {
console.error('Error fetching lines ranking:', err);
}
}, []);
// Seek to specific time
const seekTo = useCallback((timestamp) => {
setIsLive(false);
setCurrentTime(timestamp);
fetchSnapshotStats(timestamp);
// Fetch timeline for 2 hours around the timestamp
const start = new Date(timestamp.getTime() - 3600000);
const end = new Date(timestamp.getTime() + 3600000);
fetchTimeline(start, end, 5);
fetchLinesRanking(timestamp, 24);
}, [fetchSnapshotStats, fetchTimeline, fetchLinesRanking]);
// Go live
const goLive = useCallback(() => {
setIsLive(true);
setCurrentTime(new Date());
fetchCurrentStats();
// Fetch last hour timeline
const now = new Date();
const start = new Date(now.getTime() - 3600000);
fetchTimeline(start, now, 5);
fetchLinesRanking(now, 24);
}, [fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
// Skip forward/backward
const skip = useCallback((minutes) => {
const newTime = new Date(currentTime.getTime() + minutes * 60000);
seekTo(newTime);
}, [currentTime, seekTo]);
// Initial load
useEffect(() => {
const init = async () => {
setIsLoading(true);
await fetchAvailableRange();
await fetchCurrentStats();
const now = new Date();
const start = new Date(now.getTime() - 3600000);
await fetchTimeline(start, now, 5);
await fetchLinesRanking(now, 24);
setIsLoading(false);
};
init();
}, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
// Auto-refresh when live
useEffect(() => {
if (isLive) {
refreshIntervalRef.current = setInterval(() => {
fetchCurrentStats();
const now = new Date();
const start = new Date(now.getTime() - 3600000);
fetchTimeline(start, now, 5);
}, 10000); // Refresh every 10 seconds
} else {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [isLive, fetchCurrentStats, fetchTimeline]);
return {
isLive,
currentTime,
stats,
timeline,
linesRanking,
availableRange,
isLoading,
error,
seekTo,
goLive,
skip,
setIsLive,
};
}

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export function useStations() {
const [stations, setStations] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchStations = async () => {
try {
const response = await fetch(`${API_URL}/stations`);
if (!response.ok) {
throw new Error('Failed to fetch stations');
}
const data = await response.json();
// Convert lat/lon to numbers
const stationsWithNumbers = data.map(station => ({
...station,
latitude: parseFloat(station.latitude),
longitude: parseFloat(station.longitude),
}));
setStations(stationsWithNumbers);
setError(null);
} catch (err) {
console.error('Error fetching stations:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
fetchStations();
}, []);
return {
stations,
isLoading,
error,
};
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '../utils/bearing';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
/**
* Pre-calculate bearings for historical data based on consecutive positions
* @param {Array} positions - Array of positions sorted by timestamp ASC
* @returns {Array} - Positions with calculated bearings
*/
function calculateHistoricalBearings(positions) {
// Group positions by train_id
const trainPositions = new Map();
for (const pos of positions) {
if (!trainPositions.has(pos.train_id)) {
trainPositions.set(pos.train_id, []);
}
trainPositions.get(pos.train_id).push(pos);
}
// Calculate bearings for each train's positions
const result = [];
for (const [trainId, trainPos] of trainPositions) {
// Sort by timestamp (should already be sorted, but ensure)
trainPos.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
for (let i = 0; i < trainPos.length; i++) {
const current = trainPos[i];
let bearing = current.bearing; // Use existing bearing if available
if (bearing === null || bearing === undefined) {
// Calculate bearing from previous position
if (i > 0) {
const prev = trainPos[i - 1];
const distance = calculateDistance(prev.latitude, prev.longitude, current.latitude, current.longitude);
if (distance >= MIN_DISTANCE_FOR_BEARING) {
bearing = calculateBearing(prev.latitude, prev.longitude, current.latitude, current.longitude);
}
}
}
result.push({
...current,
bearing,
});
}
}
// Re-sort by timestamp to maintain chronological order
result.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
return result;
}
export function useTimeline() {
const [isTimelineMode, setIsTimelineMode] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(Date.now());
const [historyData, setHistoryData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); // 1x, 2x, 5x, 10x
const playIntervalRef = useRef(null);
// Time range: last hour by default
const [timeRange, setTimeRange] = useState({
start: Date.now() - 3600000, // 1 hour ago
end: Date.now(),
});
// Load all historical positions for the time range
const loadAllHistory = useCallback(async () => {
setIsLoading(true);
try {
const startISO = new Date(timeRange.start).toISOString();
const endISO = new Date(timeRange.end).toISOString();
// Fetch all positions in the time range with a single request
const response = await fetch(
`${API_URL}/trains/history/all?from=${startISO}&to=${endISO}&limit=10000`
);
if (!response.ok) {
console.error('Failed to fetch historical data');
setIsLoading(false);
return;
}
const allHistory = await response.json();
// Calculate bearings based on consecutive positions
const historyWithBearings = calculateHistoricalBearings(allHistory);
setHistoryData(historyWithBearings);
console.log(`Loaded ${historyWithBearings.length} historical positions with bearings calculated`);
} catch (err) {
console.error('Error loading history:', err);
}
setIsLoading(false);
}, [timeRange]);
// Get positions at a specific time
const getPositionsAtTime = useCallback((timestamp) => {
if (historyData.length === 0) return [];
// Group by train_id and get the closest position to the timestamp
const trainPositions = new Map();
for (const position of historyData) {
const posTime = new Date(position.timestamp).getTime();
// Only consider positions up to the current playback time
if (posTime <= timestamp) {
const existing = trainPositions.get(position.train_id);
if (!existing || new Date(existing.timestamp).getTime() < posTime) {
// latitude/longitude already come as numbers from the API
trainPositions.set(position.train_id, position);
}
}
}
return Array.from(trainPositions.values());
}, [historyData]);
// Toggle timeline mode
const toggleTimelineMode = useCallback(() => {
if (!isTimelineMode) {
// Entering timeline mode
setIsTimelineMode(true);
setCurrentTime(timeRange.start);
loadAllHistory();
} else {
// Exiting timeline mode
setIsTimelineMode(false);
setIsPlaying(false);
setHistoryData([]);
}
}, [isTimelineMode, timeRange.start, loadAllHistory]);
// Play/pause
const togglePlay = useCallback(() => {
setIsPlaying(prev => !prev);
}, []);
// Skip forward/backward
const skip = useCallback((seconds) => {
setCurrentTime(prev => {
const newTime = prev + (seconds * 1000);
return Math.max(timeRange.start, Math.min(timeRange.end, newTime));
});
}, [timeRange]);
// Seek to specific time
const seekTo = useCallback((timestamp) => {
setCurrentTime(timestamp);
setIsPlaying(false);
}, []);
// Change playback speed
const changeSpeed = useCallback((speed) => {
setPlaybackSpeed(speed);
}, []);
// Update time range
const updateTimeRange = useCallback((start, end) => {
setTimeRange({ start, end });
setCurrentTime(start);
if (isTimelineMode) {
loadAllHistory();
}
}, [isTimelineMode, loadAllHistory]);
// Playback effect
useEffect(() => {
if (isPlaying && isTimelineMode) {
playIntervalRef.current = setInterval(() => {
setCurrentTime(prev => {
const next = prev + (1000 * playbackSpeed); // Advance based on speed
if (next >= timeRange.end) {
setIsPlaying(false);
return timeRange.end;
}
return next;
});
}, 100); // Update every 100ms for smooth animation
} else {
if (playIntervalRef.current) {
clearInterval(playIntervalRef.current);
playIntervalRef.current = null;
}
}
return () => {
if (playIntervalRef.current) {
clearInterval(playIntervalRef.current);
}
};
}, [isPlaying, isTimelineMode, playbackSpeed, timeRange.end]);
// Get current positions based on mode
const timelinePositions = isTimelineMode ? getPositionsAtTime(currentTime) : [];
return {
isTimelineMode,
isPlaying,
isLoading,
currentTime,
timeRange,
playbackSpeed,
timelinePositions,
historyData,
toggleTimelineMode,
togglePlay,
skip,
seekTo,
changeSpeed,
updateTimeRange,
};
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { io } from 'socket.io-client';
import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '../utils/bearing';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
// Calculate bearing for trains based on previous positions
function addCalculatedBearings(newTrains, previousPositions) {
return newTrains.map(train => {
// If train already has bearing from API, use it
if (train.bearing !== null && train.bearing !== undefined) {
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
return train;
}
const prevPos = previousPositions.get(train.train_id);
let calculatedBearing = null;
if (prevPos) {
const distance = calculateDistance(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
// Only calculate bearing if the train moved enough
if (distance >= MIN_DISTANCE_FOR_BEARING) {
calculatedBearing = calculateBearing(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
}
}
// Update previous position
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
return {
...train,
bearing: calculatedBearing,
};
});
}
export function useTrains() {
const [trains, setTrains] = useState([]);
const [selectedTrain, setSelectedTrain] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const [stats, setStats] = useState({
active_trains: 0,
last_update: null,
});
const socketRef = useRef(null);
// Store previous positions to calculate bearing
const previousPositionsRef = useRef(new Map());
// Initialize WebSocket connection
useEffect(() => {
console.log('Connecting to WebSocket:', WS_URL);
const socket = io(WS_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
});
socket.on('connect', () => {
console.log('WebSocket connected');
setIsConnected(true);
setError(null);
});
socket.on('disconnect', () => {
console.log('WebSocket disconnected');
setIsConnected(false);
});
socket.on('connect_error', (err) => {
console.error('WebSocket connection error:', err);
setError(err.message);
setIsConnected(false);
});
socket.on('trains:update', (positions) => {
console.log('Received train updates:', positions.length);
const trainsWithBearing = addCalculatedBearings(positions, previousPositionsRef.current);
setTrains(trainsWithBearing);
});
socket.on('train:update', (position) => {
console.log('Received individual train update:', position.train_id);
setTrains((prev) => {
const [updatedPosition] = addCalculatedBearings([position], previousPositionsRef.current);
const index = prev.findIndex(t => t.train_id === position.train_id);
if (index >= 0) {
const updated = [...prev];
updated[index] = updatedPosition;
return updated;
}
return [...prev, updatedPosition];
});
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, []);
// Fetch initial data
useEffect(() => {
const fetchInitialData = async () => {
try {
const response = await fetch(`${API_URL}/trains/current`);
if (!response.ok) {
throw new Error('Failed to fetch trains');
}
const data = await response.json();
console.log('Fetched initial trains:', data.length);
// Store initial positions (no bearing calculation for first load)
data.forEach(train => {
previousPositionsRef.current.set(train.train_id, {
lat: train.latitude,
lon: train.longitude,
});
});
setTrains(data);
} catch (err) {
console.error('Error fetching initial data:', err);
setError(err.message);
}
};
fetchInitialData();
}, []);
// Fetch stats periodically
useEffect(() => {
const fetchStats = async () => {
try {
const response = await fetch(`${API_URL}/stats`);
if (!response.ok) return;
const data = await response.json();
setStats(data);
} catch (err) {
console.error('Error fetching stats:', err);
}
};
fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
// Subscribe to specific train
const subscribeTrain = useCallback((trainId) => {
if (socketRef.current && trainId) {
socketRef.current.emit('subscribe:train', trainId);
}
}, []);
// Unsubscribe from specific train
const unsubscribeTrain = useCallback((trainId) => {
if (socketRef.current && trainId) {
socketRef.current.emit('unsubscribe:train', trainId);
}
}, []);
// Fetch full train details
const fetchTrainDetails = useCallback(async (trainId) => {
try {
const response = await fetch(`${API_URL}/trains/${trainId}`);
if (!response.ok) return null;
return await response.json();
} catch (err) {
console.error('Error fetching train details:', err);
return null;
}
}, []);
// Select train and fetch full details
const selectTrain = useCallback(async (train) => {
if (selectedTrain) {
unsubscribeTrain(selectedTrain.train_id);
}
if (train) {
subscribeTrain(train.train_id);
// Fetch full train details including type, service name, etc.
const details = await fetchTrainDetails(train.train_id);
if (details) {
// Merge position data with full train details
// Keep all fleet data from the original train object (codLinea, estaciones, etc.)
setSelectedTrain({
...train,
train_type: details.train_type,
service_name: details.service_name,
first_seen: details.first_seen,
last_seen: details.last_seen,
metadata: details.metadata,
// Also merge fleet_data if available from details
...(details.fleet_data && {
codLinea: details.fleet_data.codLinea,
retrasoMin: details.fleet_data.retrasoMin,
codEstAct: details.fleet_data.codEstAct,
estacionActual: details.fleet_data.estacionActual,
codEstSig: details.fleet_data.codEstSig,
estacionSiguiente: details.fleet_data.estacionSiguiente,
horaLlegadaSigEst: details.fleet_data.horaLlegadaSigEst,
codEstDest: details.fleet_data.codEstDest,
estacionDestino: details.fleet_data.estacionDestino,
codEstOrig: details.fleet_data.codEstOrig,
estacionOrigen: details.fleet_data.estacionOrigen,
nucleo: details.fleet_data.nucleo,
accesible: details.fleet_data.accesible,
via: details.fleet_data.via,
}),
});
return;
}
}
setSelectedTrain(train);
}, [selectedTrain, subscribeTrain, unsubscribeTrain, fetchTrainDetails]);
return {
trains,
selectedTrain,
selectTrain,
isConnected,
error,
stats,
};
}