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
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:
183
frontend/src/hooks/useDashboard.js
Normal file
183
frontend/src/hooks/useDashboard.js
Normal 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,
|
||||
};
|
||||
}
|
||||
44
frontend/src/hooks/useStations.js
Normal file
44
frontend/src/hooks/useStations.js
Normal 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,
|
||||
};
|
||||
}
|
||||
221
frontend/src/hooks/useTimeline.js
Normal file
221
frontend/src/hooks/useTimeline.js
Normal 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,
|
||||
};
|
||||
}
|
||||
230
frontend/src/hooks/useTrains.js
Normal file
230
frontend/src/hooks/useTrains.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user