diff --git a/backend/src/api/routes/dashboard.js b/backend/src/api/routes/dashboard.js
index 2f8da43..24a5cd6 100644
--- a/backend/src/api/routes/dashboard.js
+++ b/backend/src/api/routes/dashboard.js
@@ -344,4 +344,428 @@ router.get('/available-range', async (req, res, next) => {
}
});
+// GET /dashboard/lines - Get list of all lines with basic stats
+router.get('/lines', async (req, res, next) => {
+ try {
+ const { hours = 24 } = req.query;
+ const startTime = new Date(Date.now() - hours * 3600000);
+
+ const result = await db.query(`
+ SELECT
+ line_code,
+ nucleo,
+ COUNT(DISTINCT train_id) as unique_trains,
+ COUNT(*) as observations,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND line_code IS NOT NULL
+ GROUP BY line_code, nucleo
+ HAVING COUNT(*) >= 5
+ ORDER BY unique_trains DESC
+ `, [startTime]);
+
+ const linesWithNucleoName = result.rows.map(row => ({
+ ...row,
+ nucleo_name: NUCLEO_NAMES[row.nucleo] || row.nucleo,
+ }));
+
+ res.json(linesWithNucleoName);
+ } catch (error) {
+ next(error);
+ }
+});
+
+// GET /dashboard/regions - Get list of all regions with stats
+router.get('/regions', async (req, res, next) => {
+ try {
+ const { hours = 24 } = req.query;
+ const startTime = new Date(Date.now() - hours * 3600000);
+
+ const result = await db.query(`
+ SELECT
+ nucleo,
+ COUNT(DISTINCT line_code) as line_count,
+ COUNT(DISTINCT train_id) as unique_trains,
+ COUNT(*) as observations,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ MAX(delay_minutes) as max_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND nucleo IS NOT NULL
+ GROUP BY nucleo
+ ORDER BY unique_trains DESC
+ `, [startTime]);
+
+ const regionsWithName = result.rows.map(row => ({
+ ...row,
+ nucleo_name: NUCLEO_NAMES[row.nucleo] || row.nucleo,
+ }));
+
+ res.json(regionsWithName);
+ } catch (error) {
+ next(error);
+ }
+});
+
+// GET /dashboard/line/:lineCode - Get detailed stats for a specific line
+router.get('/line/:lineCode', async (req, res, next) => {
+ try {
+ const { lineCode } = req.params;
+ const { nucleo, hours = 24 } = req.query;
+ const startTime = new Date(Date.now() - hours * 3600000);
+
+ // Basic stats for the line
+ const statsResult = await db.query(`
+ SELECT
+ line_code,
+ nucleo,
+ COUNT(DISTINCT train_id) as unique_trains,
+ COUNT(*) as observations,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ MAX(delay_minutes) as max_delay,
+ MIN(delay_minutes) as min_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct,
+ COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
+ COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
+ COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
+ COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND line_code = $2
+ ${nucleo ? 'AND nucleo = $3' : ''}
+ GROUP BY line_code, nucleo
+ `, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
+
+ if (statsResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Line not found or no data available' });
+ }
+
+ // Timeline for this line
+ const timelineResult = await db.query(`
+ WITH time_buckets AS (
+ SELECT
+ date_trunc('hour', recorded_at) as time_bucket,
+ train_id,
+ delay_minutes
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND line_code = $2
+ ${nucleo ? 'AND nucleo = $3' : ''}
+ )
+ SELECT
+ time_bucket,
+ COUNT(DISTINCT train_id) as train_count,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct
+ FROM time_buckets
+ GROUP BY time_bucket
+ ORDER BY time_bucket
+ `, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
+
+ // Top stations for this line
+ const stationsResult = await db.query(`
+ SELECT
+ origin_station_code as station_code,
+ COUNT(DISTINCT train_id) as train_count,
+ AVG(delay_minutes)::FLOAT as avg_delay
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND line_code = $2
+ ${nucleo ? 'AND nucleo = $3' : ''}
+ AND origin_station_code IS NOT NULL
+ GROUP BY origin_station_code
+ ORDER BY train_count DESC
+ LIMIT 10
+ `, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
+
+ const stats = statsResult.rows[0];
+ res.json({
+ line_code: lineCode,
+ nucleo: stats.nucleo,
+ nucleo_name: NUCLEO_NAMES[stats.nucleo] || stats.nucleo,
+ stats: {
+ unique_trains: parseInt(stats.unique_trains, 10),
+ observations: parseInt(stats.observations, 10),
+ avg_delay: parseFloat(stats.avg_delay) || 0,
+ max_delay: parseInt(stats.max_delay, 10),
+ min_delay: parseInt(stats.min_delay, 10),
+ punctuality_pct: parseFloat(stats.punctuality_pct) || 0,
+ punctuality_breakdown: {
+ on_time: parseInt(stats.on_time, 10),
+ minor_delay: parseInt(stats.minor_delay, 10),
+ moderate_delay: parseInt(stats.moderate_delay, 10),
+ severe_delay: parseInt(stats.severe_delay, 10),
+ },
+ },
+ timeline: timelineResult.rows.map(row => ({
+ timestamp: row.time_bucket,
+ train_count: parseInt(row.train_count, 10),
+ avg_delay: parseFloat(row.avg_delay) || 0,
+ punctuality_pct: parseFloat(row.punctuality_pct) || 0,
+ })),
+ top_stations: stationsResult.rows.map(row => ({
+ station_code: row.station_code,
+ train_count: parseInt(row.train_count, 10),
+ avg_delay: parseFloat(row.avg_delay) || 0,
+ })),
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+// GET /dashboard/region/:nucleo - Get detailed stats for a specific region
+router.get('/region/:nucleo', async (req, res, next) => {
+ try {
+ const { nucleo } = req.params;
+ const { hours = 24 } = req.query;
+ const startTime = new Date(Date.now() - hours * 3600000);
+
+ // Basic stats for the region
+ const statsResult = await db.query(`
+ SELECT
+ nucleo,
+ COUNT(DISTINCT line_code) as line_count,
+ COUNT(DISTINCT train_id) as unique_trains,
+ COUNT(*) as observations,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ MAX(delay_minutes) as max_delay,
+ MIN(delay_minutes) as min_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct,
+ COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
+ COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
+ COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
+ COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND nucleo = $2
+ GROUP BY nucleo
+ `, [startTime, nucleo]);
+
+ if (statsResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Region not found or no data available' });
+ }
+
+ // Timeline for this region
+ const timelineResult = await db.query(`
+ WITH time_buckets AS (
+ SELECT
+ date_trunc('hour', recorded_at) as time_bucket,
+ train_id,
+ delay_minutes
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND nucleo = $2
+ )
+ SELECT
+ time_bucket,
+ COUNT(DISTINCT train_id) as train_count,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct
+ FROM time_buckets
+ GROUP BY time_bucket
+ ORDER BY time_bucket
+ `, [startTime, nucleo]);
+
+ // Lines in this region
+ const linesResult = await db.query(`
+ SELECT
+ line_code,
+ COUNT(DISTINCT train_id) as unique_trains,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct
+ FROM train_punctuality
+ WHERE recorded_at >= $1
+ AND nucleo = $2
+ AND line_code IS NOT NULL
+ GROUP BY line_code
+ ORDER BY unique_trains DESC
+ LIMIT 15
+ `, [startTime, nucleo]);
+
+ const stats = statsResult.rows[0];
+ res.json({
+ nucleo,
+ nucleo_name: NUCLEO_NAMES[nucleo] || nucleo,
+ stats: {
+ line_count: parseInt(stats.line_count, 10),
+ unique_trains: parseInt(stats.unique_trains, 10),
+ observations: parseInt(stats.observations, 10),
+ avg_delay: parseFloat(stats.avg_delay) || 0,
+ max_delay: parseInt(stats.max_delay, 10),
+ min_delay: parseInt(stats.min_delay, 10),
+ punctuality_pct: parseFloat(stats.punctuality_pct) || 0,
+ punctuality_breakdown: {
+ on_time: parseInt(stats.on_time, 10),
+ minor_delay: parseInt(stats.minor_delay, 10),
+ moderate_delay: parseInt(stats.moderate_delay, 10),
+ severe_delay: parseInt(stats.severe_delay, 10),
+ },
+ },
+ timeline: timelineResult.rows.map(row => ({
+ timestamp: row.time_bucket,
+ train_count: parseInt(row.train_count, 10),
+ avg_delay: parseFloat(row.avg_delay) || 0,
+ punctuality_pct: parseFloat(row.punctuality_pct) || 0,
+ })),
+ lines: linesResult.rows.map(row => ({
+ line_code: row.line_code,
+ unique_trains: parseInt(row.unique_trains, 10),
+ avg_delay: parseFloat(row.avg_delay) || 0,
+ punctuality_pct: parseFloat(row.punctuality_pct) || 0,
+ })),
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+// GET /dashboard/compare - Compare stats between two date ranges
+router.get('/compare', async (req, res, next) => {
+ try {
+ const { start1, end1, start2, end2, lineCode, nucleo } = req.query;
+
+ if (!start1 || !end1 || !start2 || !end2) {
+ return res.status(400).json({
+ error: 'Required parameters: start1, end1, start2, end2'
+ });
+ }
+
+ const range1Start = new Date(start1);
+ const range1End = new Date(end1);
+ const range2Start = new Date(start2);
+ const range2End = new Date(end2);
+
+ // Build WHERE clause based on filters
+ let filterClause = '';
+ const params1 = [range1Start, range1End];
+ const params2 = [range2Start, range2End];
+
+ if (lineCode) {
+ filterClause += ` AND line_code = $3`;
+ params1.push(lineCode);
+ params2.push(lineCode);
+ }
+ if (nucleo) {
+ const nucleoParamIndex = lineCode ? 4 : 3;
+ filterClause += ` AND nucleo = $${nucleoParamIndex}`;
+ params1.push(nucleo);
+ params2.push(nucleo);
+ }
+
+ const queryText = `
+ SELECT
+ COUNT(DISTINCT train_id) as unique_trains,
+ COUNT(*) as observations,
+ AVG(delay_minutes)::FLOAT as avg_delay,
+ MAX(delay_minutes) as max_delay,
+ ROUND(
+ COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
+ NULLIF(COUNT(*), 0) * 100, 1
+ ) as punctuality_pct,
+ COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
+ COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
+ COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
+ COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
+ FROM train_punctuality
+ WHERE recorded_at BETWEEN $1 AND $2
+ ${filterClause}
+ `;
+
+ const [range1Result, range2Result] = await Promise.all([
+ db.query(queryText, params1),
+ db.query(queryText, params2),
+ ]);
+
+ const range1Stats = range1Result.rows[0] || {};
+ const range2Stats = range2Result.rows[0] || {};
+
+ // Calculate differences
+ const calculateDiff = (val1, val2) => {
+ if (!val1 || !val2) return null;
+ return parseFloat(val1) - parseFloat(val2);
+ };
+
+ const calculatePctChange = (val1, val2) => {
+ if (!val1 || !val2 || parseFloat(val2) === 0) return null;
+ return ((parseFloat(val1) - parseFloat(val2)) / parseFloat(val2) * 100).toFixed(1);
+ };
+
+ res.json({
+ range1: {
+ start: range1Start.toISOString(),
+ end: range1End.toISOString(),
+ stats: {
+ unique_trains: parseInt(range1Stats.unique_trains, 10) || 0,
+ observations: parseInt(range1Stats.observations, 10) || 0,
+ avg_delay: parseFloat(range1Stats.avg_delay) || 0,
+ max_delay: parseInt(range1Stats.max_delay, 10) || 0,
+ punctuality_pct: parseFloat(range1Stats.punctuality_pct) || 0,
+ punctuality_breakdown: {
+ on_time: parseInt(range1Stats.on_time, 10) || 0,
+ minor_delay: parseInt(range1Stats.minor_delay, 10) || 0,
+ moderate_delay: parseInt(range1Stats.moderate_delay, 10) || 0,
+ severe_delay: parseInt(range1Stats.severe_delay, 10) || 0,
+ },
+ },
+ },
+ range2: {
+ start: range2Start.toISOString(),
+ end: range2End.toISOString(),
+ stats: {
+ unique_trains: parseInt(range2Stats.unique_trains, 10) || 0,
+ observations: parseInt(range2Stats.observations, 10) || 0,
+ avg_delay: parseFloat(range2Stats.avg_delay) || 0,
+ max_delay: parseInt(range2Stats.max_delay, 10) || 0,
+ punctuality_pct: parseFloat(range2Stats.punctuality_pct) || 0,
+ punctuality_breakdown: {
+ on_time: parseInt(range2Stats.on_time, 10) || 0,
+ minor_delay: parseInt(range2Stats.minor_delay, 10) || 0,
+ moderate_delay: parseInt(range2Stats.moderate_delay, 10) || 0,
+ severe_delay: parseInt(range2Stats.severe_delay, 10) || 0,
+ },
+ },
+ },
+ comparison: {
+ unique_trains_diff: calculateDiff(range1Stats.unique_trains, range2Stats.unique_trains),
+ avg_delay_diff: calculateDiff(range1Stats.avg_delay, range2Stats.avg_delay),
+ punctuality_diff: calculateDiff(range1Stats.punctuality_pct, range2Stats.punctuality_pct),
+ unique_trains_pct_change: calculatePctChange(range1Stats.unique_trains, range2Stats.unique_trains),
+ avg_delay_pct_change: calculatePctChange(range1Stats.avg_delay, range2Stats.avg_delay),
+ punctuality_pct_change: calculatePctChange(range1Stats.punctuality_pct, range2Stats.punctuality_pct),
+ },
+ filters: {
+ line_code: lineCode || null,
+ nucleo: nucleo || null,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
export default router;
diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx
index 6400bd8..e46ba16 100644
--- a/frontend/src/components/Dashboard.jsx
+++ b/frontend/src/components/Dashboard.jsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import {
BarChart3,
Clock,
@@ -12,8 +12,12 @@ import {
SkipForward,
Radio,
Calendar,
+ MapPin,
+ ArrowLeft,
+ GitCompare,
+ ChevronDown,
} from 'lucide-react';
-import { useDashboard } from '../hooks/useDashboard';
+import { useDashboard, VIEW_MODES } from '../hooks/useDashboard';
// Mini chart component for timeline
function MiniChart({ data, dataKey, color, height = 60 }) {
@@ -153,7 +157,7 @@ function PunctualityDonut({ data }) {
}
// Lines ranking table
-function LinesTable({ lines }) {
+function LinesTable({ lines, onLineClick }) {
if (!lines || lines.length === 0) {
return
Sin datos de lineas
;
}
@@ -167,7 +171,11 @@ function LinesTable({ lines }) {
Puntualidad
{lines.slice(0, 10).map((line, index) => (
-
+
onLineClick && onLineClick(line.line_code, line.nucleo)}
+ >
{line.line_code}
{line.nucleo_name && ({line.nucleo_name})}
@@ -185,6 +193,510 @@ function LinesTable({ lines }) {
);
}
+// View mode tabs
+function ViewTabs({ viewMode, onChangeView, onCompareClick }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+// Line selector dropdown
+function LineSelector({ lines, selectedLine, onSelect }) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const selectedLabel = selectedLine
+ ? `${selectedLine.lineCode} (${lines.find(l => l.line_code === selectedLine.lineCode && l.nucleo === selectedLine.nucleo)?.nucleo_name || ''})`
+ : 'Seleccionar linea...';
+
+ return (
+
+
+ {isOpen && (
+
+ {lines.map((line, index) => (
+
{
+ onSelect(line.line_code, line.nucleo);
+ setIsOpen(false);
+ }}
+ >
+ {line.line_code}
+ {line.nucleo_name}
+ {line.unique_trains} trenes
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Region selector dropdown
+function RegionSelector({ regions, selectedRegion, onSelect }) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const selectedLabel = selectedRegion
+ ? regions.find(r => r.nucleo === selectedRegion)?.nucleo_name || selectedRegion
+ : 'Seleccionar region...';
+
+ return (
+
+
+ {isOpen && (
+
+ {regions.map((region) => (
+
{
+ onSelect(region.nucleo);
+ setIsOpen(false);
+ }}
+ >
+ {region.nucleo_name}
+ {region.line_count} lineas
+ {region.unique_trains} trenes
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Line detail view
+function LineDetailView({ lineDetails, onBack }) {
+ if (!lineDetails) return null;
+
+ return (
+
+
+
+
+
+ Linea {lineDetails.line_code}
+ {lineDetails.nucleo_name}
+
+
+
+
+
+
+ 5 ? '#E74C3C' : '#27AE60'}
+ />
+
+
+
+
+
+
+
+ Distribucion de Puntualidad
+
+
+
+
+
+
+
+ Evolucion Temporal
+
+
+
+ Trenes
+
+
+
+ Puntualidad %
+
+
+
+ Retraso Medio
+
+
+
+
+
+ {lineDetails.top_stations && lineDetails.top_stations.length > 0 && (
+
+
+
+ Estaciones Principales
+
+
+ {lineDetails.top_stations.map((station, index) => (
+
+ #{index + 1}
+ {station.station_code}
+ {station.train_count} trenes
+ 5 ? '#E74C3C' : '#27AE60' }}
+ >
+ {station.avg_delay.toFixed(1)} min
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+// Region detail view
+function RegionDetailView({ regionDetails, onBack, onLineClick }) {
+ if (!regionDetails) return null;
+
+ return (
+
+
+
+
+
+ {regionDetails.nucleo_name}
+
+
+
+
+
+
+
+ 5 ? '#E74C3C' : '#27AE60'}
+ />
+
+
+
+
+
+
+ Distribucion de Puntualidad
+
+
+
+
+
+
+
+ Evolucion Temporal
+
+
+
+ Trenes
+
+
+
+ Puntualidad %
+
+
+
+ Retraso Medio
+
+
+
+
+
+
+
+
+ Lineas en la Region
+
+
+ {regionDetails.lines.map((line) => (
+
onLineClick(line.line_code, regionDetails.nucleo)}
+ >
+ {line.line_code}
+ {line.unique_trains} trenes
+ = 80 ? '#27AE60' : '#E74C3C' }}
+ >
+ {line.punctuality_pct}%
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Date range picker
+function DateRangePicker({ label, range, onChange }) {
+ const formatDateForInput = (date) => {
+ if (!date) return '';
+ return date.toISOString().slice(0, 16);
+ };
+
+ return (
+
+ );
+}
+
+// Compare view
+function CompareView({ compareData, onBack, availableRange }) {
+ if (!compareData) return null;
+
+ const { range1, range2, comparison } = compareData;
+
+ const formatDate = (dateStr) => {
+ return new Date(dateStr).toLocaleDateString('es-ES', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ const renderDiff = (diff, isPositiveGood = true) => {
+ if (diff === null) return -;
+ const isPositive = diff > 0;
+ const isGood = isPositiveGood ? isPositive : !isPositive;
+ return (
+
+ {isPositive ? '+' : ''}{typeof diff === 'number' ? diff.toFixed(1) : diff}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ Comparacion de Periodos
+
+
+
+
+
+ Periodo 1: {formatDate(range1.start)} - {formatDate(range1.end)}
+
+
+ Periodo 2: {formatDate(range2.start)} - {formatDate(range2.end)}
+
+
+
+
+
+
Trenes Unicos
+
+
+ Periodo 1
+ {range1.stats.unique_trains}
+
+
+ {renderDiff(comparison.unique_trains_diff, true)}
+ {comparison.unique_trains_pct_change && (
+ ({comparison.unique_trains_pct_change}%)
+ )}
+
+
+ Periodo 2
+ {range2.stats.unique_trains}
+
+
+
+
+
+
Puntualidad
+
+
+ Periodo 1
+ {range1.stats.punctuality_pct}%
+
+
+ {renderDiff(comparison.punctuality_diff, true)}
+ {comparison.punctuality_pct_change && (
+ ({comparison.punctuality_pct_change}%)
+ )}
+
+
+ Periodo 2
+ {range2.stats.punctuality_pct}%
+
+
+
+
+
+
Retraso Medio
+
+
+ Periodo 1
+ {range1.stats.avg_delay.toFixed(1)} min
+
+
+ {renderDiff(comparison.avg_delay_diff, false)}
+ {comparison.avg_delay_pct_change && (
+ ({comparison.avg_delay_pct_change}%)
+ )}
+
+
+ Periodo 2
+ {range2.stats.avg_delay.toFixed(1)} min
+
+
+
+
+
+
Distribucion de Puntualidad
+
+
+
+
+ );
+}
+
+// Compare setup modal
+function CompareSetup({ availableRange, onStartCompare, onCancel }) {
+ const [range1, setRange1] = useState({
+ start: new Date(Date.now() - 7 * 24 * 3600000),
+ end: new Date(),
+ });
+ const [range2, setRange2] = useState({
+ start: new Date(Date.now() - 14 * 24 * 3600000),
+ end: new Date(Date.now() - 7 * 24 * 3600000),
+ });
+
+ const handleSubmit = () => {
+ if (range1.start && range1.end && range2.start && range2.end) {
+ onStartCompare(range1, range2);
+ }
+ };
+
+ return (
+
+
+
+ Configurar Comparacion
+
+
+
+
+
+
+
+
+ );
+}
+
// Time control bar
function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) {
const formatTime = (date) => {
@@ -282,14 +794,49 @@ export function Dashboard() {
seekTo,
goLive,
skip,
+ // New view states
+ viewMode,
+ setViewMode,
+ allLines,
+ allRegions,
+ selectedLine,
+ selectedRegion,
+ lineDetails,
+ regionDetails,
+ compareData,
+ // New view actions
+ selectLine,
+ selectRegion,
+ startCompare,
+ goToGeneral,
} = useDashboard();
+ const [showCompareSetup, setShowCompareSetup] = useState(false);
+
// Calculate status totals
const statusTotal = useMemo(() => {
if (!stats?.status_breakdown) return 0;
return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0);
}, [stats]);
+ const handleViewChange = (newView) => {
+ if (newView === VIEW_MODES.GENERAL) {
+ goToGeneral();
+ } else if (newView === VIEW_MODES.LINE || newView === VIEW_MODES.REGION) {
+ // Clear any existing selection and switch to selection view
+ setViewMode(newView);
+ }
+ };
+
+ const handleCompareClick = () => {
+ setShowCompareSetup(true);
+ };
+
+ const handleStartCompare = (range1, range2) => {
+ setShowCompareSetup(false);
+ startCompare(range1, range2);
+ };
+
if (error) {
return (
@@ -300,8 +847,186 @@ export function Dashboard() {
);
}
+ // Render compare setup modal
+ if (showCompareSetup) {
+ return (
+
+
+ setShowCompareSetup(false)}
+ />
+
+ );
+ }
+
+ // Render line detail view
+ if (viewMode === VIEW_MODES.LINE && lineDetails) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Render region detail view
+ if (viewMode === VIEW_MODES.REGION && regionDetails) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Render compare view
+ if (viewMode === VIEW_MODES.COMPARE && compareData) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Render line selection view
+ if (viewMode === VIEW_MODES.LINE && !lineDetails) {
+ return (
+
+
+
+
+
+ Seleccionar Linea
+
+
+ {allLines.length > 0 && (
+
+ {allLines.slice(0, 12).map((line, index) => (
+
selectLine(line.line_code, line.nucleo)}
+ >
+
+ {line.line_code}
+ {line.nucleo_name}
+
+
+
+
+ {line.unique_trains} trenes
+
+
= 80 ? '#27AE60' : '#E74C3C' }}>
+
+ {line.punctuality_pct}%
+
+
+
+ ))}
+
+ )}
+
+
+ );
+ }
+
+ // Render region selection view
+ if (viewMode === VIEW_MODES.REGION && !regionDetails) {
+ return (
+
+
+
+
+
+ Seleccionar Region
+
+
+ {allRegions.length > 0 && (
+
+ {allRegions.map((region) => (
+
selectRegion(region.nucleo)}
+ >
+
+ {region.nucleo_name}
+
+
+
+
+ {region.line_count} lineas
+
+
+
+ {region.unique_trains} trenes
+
+
= 80 ? '#27AE60' : '#E74C3C' }}>
+
+ {region.punctuality_pct}%
+
+
+
+ ))}
+
+ )}
+
+
+ );
+ }
+
+ // Render general view (default)
return (
+
+
Ranking de Lineas (Peor Puntualidad)
-
+
>
diff --git a/frontend/src/hooks/useDashboard.js b/frontend/src/hooks/useDashboard.js
index 85ae07e..d4ff1a1 100644
--- a/frontend/src/hooks/useDashboard.js
+++ b/frontend/src/hooks/useDashboard.js
@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
+// View modes
+export const VIEW_MODES = {
+ GENERAL: 'general',
+ LINE: 'line',
+ REGION: 'region',
+ COMPARE: 'compare',
+};
+
export function useDashboard() {
const [isLive, setIsLive] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
@@ -12,6 +20,20 @@ export function useDashboard() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ // New states for views
+ const [viewMode, setViewMode] = useState(VIEW_MODES.GENERAL);
+ const [allLines, setAllLines] = useState([]);
+ const [allRegions, setAllRegions] = useState([]);
+ const [selectedLine, setSelectedLine] = useState(null);
+ const [selectedRegion, setSelectedRegion] = useState(null);
+ const [lineDetails, setLineDetails] = useState(null);
+ const [regionDetails, setRegionDetails] = useState(null);
+ const [compareData, setCompareData] = useState(null);
+ const [compareRanges, setCompareRanges] = useState({
+ range1: { start: null, end: null },
+ range2: { start: null, end: null },
+ });
+
const refreshIntervalRef = useRef(null);
// Fetch available data range
@@ -94,6 +116,123 @@ export function useDashboard() {
}
}, []);
+ // Fetch all lines
+ const fetchAllLines = useCallback(async (hours = 24) => {
+ try {
+ const response = await fetch(`${API_URL}/dashboard/lines?hours=${hours}`);
+ if (!response.ok) throw new Error('Failed to fetch lines');
+ const data = await response.json();
+ setAllLines(data);
+ } catch (err) {
+ console.error('Error fetching all lines:', err);
+ }
+ }, []);
+
+ // Fetch all regions
+ const fetchAllRegions = useCallback(async (hours = 24) => {
+ try {
+ const response = await fetch(`${API_URL}/dashboard/regions?hours=${hours}`);
+ if (!response.ok) throw new Error('Failed to fetch regions');
+ const data = await response.json();
+ setAllRegions(data);
+ } catch (err) {
+ console.error('Error fetching all regions:', err);
+ }
+ }, []);
+
+ // Fetch line details
+ const fetchLineDetails = useCallback(async (lineCode, nucleo, hours = 24) => {
+ try {
+ setIsLoading(true);
+ const params = new URLSearchParams({ hours: hours.toString() });
+ if (nucleo) params.append('nucleo', nucleo);
+ const response = await fetch(`${API_URL}/dashboard/line/${lineCode}?${params}`);
+ if (!response.ok) throw new Error('Failed to fetch line details');
+ const data = await response.json();
+ setLineDetails(data);
+ setError(null);
+ } catch (err) {
+ console.error('Error fetching line details:', err);
+ setError(err.message);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Fetch region details
+ const fetchRegionDetails = useCallback(async (nucleo, hours = 24) => {
+ try {
+ setIsLoading(true);
+ const response = await fetch(`${API_URL}/dashboard/region/${nucleo}?hours=${hours}`);
+ if (!response.ok) throw new Error('Failed to fetch region details');
+ const data = await response.json();
+ setRegionDetails(data);
+ setError(null);
+ } catch (err) {
+ console.error('Error fetching region details:', err);
+ setError(err.message);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Fetch comparison data
+ const fetchCompareData = useCallback(async (range1, range2, lineCode = null, nucleo = null) => {
+ try {
+ setIsLoading(true);
+ const params = new URLSearchParams({
+ start1: range1.start.toISOString(),
+ end1: range1.end.toISOString(),
+ start2: range2.start.toISOString(),
+ end2: range2.end.toISOString(),
+ });
+ if (lineCode) params.append('lineCode', lineCode);
+ if (nucleo) params.append('nucleo', nucleo);
+
+ const response = await fetch(`${API_URL}/dashboard/compare?${params}`);
+ if (!response.ok) throw new Error('Failed to fetch comparison data');
+ const data = await response.json();
+ setCompareData(data);
+ setError(null);
+ } catch (err) {
+ console.error('Error fetching comparison data:', err);
+ setError(err.message);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Select a line to view
+ const selectLine = useCallback((lineCode, nucleo = null) => {
+ setSelectedLine({ lineCode, nucleo });
+ setViewMode(VIEW_MODES.LINE);
+ fetchLineDetails(lineCode, nucleo);
+ }, [fetchLineDetails]);
+
+ // Select a region to view
+ const selectRegion = useCallback((nucleo) => {
+ setSelectedRegion(nucleo);
+ setViewMode(VIEW_MODES.REGION);
+ fetchRegionDetails(nucleo);
+ }, [fetchRegionDetails]);
+
+ // Start comparison mode
+ const startCompare = useCallback((range1, range2, lineCode = null, nucleo = null) => {
+ setCompareRanges({ range1, range2 });
+ setViewMode(VIEW_MODES.COMPARE);
+ fetchCompareData(range1, range2, lineCode, nucleo);
+ }, [fetchCompareData]);
+
+ // Go back to general view
+ const goToGeneral = useCallback(() => {
+ setViewMode(VIEW_MODES.GENERAL);
+ setSelectedLine(null);
+ setSelectedRegion(null);
+ setLineDetails(null);
+ setRegionDetails(null);
+ setCompareData(null);
+ }, []);
+
// Seek to specific time
const seekTo = useCallback((timestamp) => {
setIsLive(false);
@@ -137,11 +276,13 @@ export function useDashboard() {
const start = new Date(now.getTime() - 3600000);
await fetchTimeline(start, now, 5);
await fetchLinesRanking(now, 24);
+ await fetchAllLines(24);
+ await fetchAllRegions(24);
setIsLoading(false);
};
init();
- }, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
+ }, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking, fetchAllLines, fetchAllRegions]);
// Auto-refresh when live
useEffect(() => {
@@ -167,6 +308,7 @@ export function useDashboard() {
}, [isLive, fetchCurrentStats, fetchTimeline]);
return {
+ // Original state
isLive,
currentTime,
stats,
@@ -179,5 +321,23 @@ export function useDashboard() {
goLive,
skip,
setIsLive,
+ // New view states
+ viewMode,
+ setViewMode,
+ allLines,
+ allRegions,
+ selectedLine,
+ selectedRegion,
+ lineDetails,
+ regionDetails,
+ compareData,
+ compareRanges,
+ // New view actions
+ selectLine,
+ selectRegion,
+ startCompare,
+ goToGeneral,
+ fetchAllLines,
+ fetchAllRegions,
};
}
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
index beb9a34..44549e4 100644
--- a/frontend/src/styles/index.css
+++ b/frontend/src/styles/index.css
@@ -835,6 +835,522 @@ body {
font-size: 0.8em;
}
+/* View Tabs */
+.view-tabs {
+ display: flex;
+ gap: 8px;
+ background: white;
+ padding: 15px 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ flex-wrap: wrap;
+}
+
+.view-tab {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 8px;
+ background: #f5f6fa;
+ color: #666;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.view-tab:hover {
+ background: #e0e0e0;
+ color: #333;
+}
+
+.view-tab.active {
+ background: #3498DB;
+ color: white;
+}
+
+/* Selector Container */
+.selector-container {
+ position: relative;
+ margin-bottom: 20px;
+}
+
+.selector-button {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ max-width: 400px;
+ padding: 12px 16px;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ background: white;
+ font-size: 1rem;
+ color: #333;
+ cursor: pointer;
+ transition: border-color 0.2s;
+}
+
+.selector-button:hover {
+ border-color: #3498DB;
+}
+
+.selector-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-width: 400px;
+ max-height: 300px;
+ overflow-y: auto;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 100;
+}
+
+.selector-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.selector-option:hover {
+ background: #f5f6fa;
+}
+
+.selector-option .line-code,
+.selector-option .region-name {
+ font-weight: 600;
+ color: #3498DB;
+}
+
+.selector-option .line-nucleo,
+.selector-option .region-lines {
+ color: #666;
+ font-size: 0.85rem;
+}
+
+.selector-option .line-trains,
+.selector-option .region-trains {
+ margin-left: auto;
+ font-size: 0.85rem;
+ color: #999;
+}
+
+/* Selection View */
+.selection-view {
+ background: white;
+ border-radius: 12px;
+ padding: 25px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.selection-view h2 {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 1.5rem;
+ color: #333;
+ margin-bottom: 20px;
+}
+
+.selection-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 15px;
+ margin-top: 20px;
+}
+
+.selection-card {
+ background: #f9f9f9;
+ border: 2px solid #e0e0e0;
+ border-radius: 10px;
+ padding: 15px;
+ transition: all 0.2s;
+}
+
+.selection-card.clickable {
+ cursor: pointer;
+}
+
+.selection-card.clickable:hover {
+ border-color: #3498DB;
+ box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);
+}
+
+.selection-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.selection-card-header .line-code,
+.selection-card-header .region-name {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #3498DB;
+}
+
+.selection-card-header .line-nucleo {
+ font-size: 0.85rem;
+ color: #666;
+}
+
+.selection-card-stats {
+ display: flex;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.mini-stat {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 0.85rem;
+ color: #666;
+}
+
+/* Detail View */
+.detail-view {
+ padding: 10px 0;
+}
+
+.detail-header {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ margin-bottom: 25px;
+}
+
+.detail-header h2 {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 1.5rem;
+ color: #333;
+ margin: 0;
+}
+
+.detail-subtitle {
+ font-size: 1rem;
+ font-weight: 400;
+ color: #666;
+ margin-left: 10px;
+}
+
+.back-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 8px;
+ background: #f5f6fa;
+ color: #666;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.back-button:hover {
+ background: #e0e0e0;
+ color: #333;
+}
+
+/* Stations List */
+.stations-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.station-item {
+ display: grid;
+ grid-template-columns: 40px 1fr auto auto;
+ gap: 10px;
+ align-items: center;
+ padding: 10px;
+ background: #f9f9f9;
+ border-radius: 6px;
+}
+
+.station-rank {
+ font-weight: 600;
+ color: #999;
+}
+
+.station-code {
+ font-weight: 600;
+ color: #333;
+}
+
+.station-trains {
+ font-size: 0.85rem;
+ color: #666;
+}
+
+.station-delay {
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+/* Region Lines List */
+.region-lines-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.region-line-item {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ gap: 15px;
+ align-items: center;
+ padding: 12px;
+ background: #f9f9f9;
+ border-radius: 6px;
+ transition: all 0.2s;
+}
+
+.region-line-item.clickable {
+ cursor: pointer;
+}
+
+.region-line-item.clickable:hover {
+ background: #e8f4fc;
+}
+
+.region-line-item .line-code {
+ font-weight: 600;
+ color: #3498DB;
+}
+
+.region-line-item .line-trains {
+ font-size: 0.85rem;
+ color: #666;
+}
+
+.region-line-item .line-punctuality {
+ font-weight: 600;
+}
+
+/* Compare Setup */
+.compare-setup {
+ background: white;
+ border-radius: 12px;
+ padding: 30px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ max-width: 600px;
+}
+
+.compare-setup h3 {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 1.3rem;
+ color: #333;
+ margin-bottom: 25px;
+}
+
+.date-range-picker {
+ margin-bottom: 20px;
+}
+
+.date-range-picker label {
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #666;
+ margin-bottom: 8px;
+}
+
+.date-range-inputs {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.date-range-inputs input {
+ padding: 10px 12px;
+ border: 2px solid #e0e0e0;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ transition: border-color 0.2s;
+}
+
+.date-range-inputs input:focus {
+ outline: none;
+ border-color: #3498DB;
+}
+
+.date-range-inputs span {
+ color: #666;
+}
+
+.compare-setup-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ margin-top: 30px;
+}
+
+.btn-primary,
+.btn-secondary {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-primary {
+ background: #3498DB;
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #2980B9;
+}
+
+.btn-secondary {
+ background: #f5f6fa;
+ color: #666;
+}
+
+.btn-secondary:hover {
+ background: #e0e0e0;
+}
+
+/* Compare View */
+.compare-ranges-info {
+ display: flex;
+ gap: 30px;
+ margin-bottom: 25px;
+ flex-wrap: wrap;
+}
+
+.compare-range-label {
+ font-size: 0.95rem;
+ color: #666;
+}
+
+.compare-range-label strong {
+ color: #333;
+}
+
+.compare-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.compare-card {
+ background: white;
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.compare-card.wide {
+ grid-column: 1 / -1;
+}
+
+.compare-card h3 {
+ font-size: 1rem;
+ color: #666;
+ margin-bottom: 15px;
+}
+
+.compare-values {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.compare-value {
+ text-align: center;
+}
+
+.compare-label {
+ display: block;
+ font-size: 0.8rem;
+ color: #999;
+ margin-bottom: 5px;
+}
+
+.compare-number {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #333;
+}
+
+.compare-diff {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 10px;
+ background: #f9f9f9;
+ border-radius: 8px;
+}
+
+.diff-value {
+ font-size: 1.2rem;
+ font-weight: 600;
+}
+
+.diff-value.positive {
+ color: #27AE60;
+}
+
+.diff-value.negative {
+ color: #E74C3C;
+}
+
+.diff-neutral {
+ color: #999;
+}
+
+.pct-change {
+ font-size: 0.8rem;
+ color: #666;
+ margin-top: 3px;
+}
+
+.compare-punctuality {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 30px;
+}
+
+.compare-punctuality-side h4 {
+ font-size: 0.9rem;
+ color: #666;
+ margin-bottom: 15px;
+ text-align: center;
+}
+
+/* Clickable rows */
+.lines-table-row.clickable {
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.lines-table-row.clickable:hover {
+ background: #e8f4fc;
+}
+
/* Responsive */
@media (max-width: 768px) {
.sidebar {
@@ -854,4 +1370,21 @@ body {
.timeline-container {
right: 20px;
}
+
+ .view-tabs {
+ flex-wrap: wrap;
+ }
+
+ .view-tab {
+ flex: 1 1 calc(50% - 4px);
+ justify-content: center;
+ }
+
+ .compare-punctuality {
+ grid-template-columns: 1fr;
+ }
+
+ .selection-grid {
+ grid-template-columns: 1fr;
+ }
}