From f979ad67a07b181d39fe9dcfe51726d5e38c6539 Mon Sep 17 00:00:00 2001 From: Millaguie Date: Fri, 28 Nov 2025 15:03:22 +0100 Subject: [PATCH] feat: improve dashboard --- backend/src/api/routes/dashboard.js | 424 +++++++++++++++ frontend/src/components/Dashboard.jsx | 735 +++++++++++++++++++++++++- frontend/src/hooks/useDashboard.js | 162 +++++- frontend/src/styles/index.css | 533 +++++++++++++++++++ 4 files changed, 1848 insertions(+), 6 deletions(-) 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 ( +
+ +
+ onChange({ ...range, start: new Date(e.target.value) })} + /> + a + onChange({ ...range, end: new Date(e.target.value) })} + /> +
+
+ ); +} + +// 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

+
+
+

Periodo 1

+ +
+
+

Periodo 2

+ +
+
+
+
+
+ ); +} + +// 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; + } }