feat: improve dashboard
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 <div className="empty-state">Sin datos de lineas</div>;
|
||||
}
|
||||
@@ -167,7 +171,11 @@ function LinesTable({ lines }) {
|
||||
<span>Puntualidad</span>
|
||||
</div>
|
||||
{lines.slice(0, 10).map((line, index) => (
|
||||
<div key={`${line.nucleo}:${line.line_code}-${index}`} className="lines-table-row">
|
||||
<div
|
||||
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||
className={`lines-table-row ${onLineClick ? 'clickable' : ''}`}
|
||||
onClick={() => onLineClick && onLineClick(line.line_code, line.nucleo)}
|
||||
>
|
||||
<span className="line-code">
|
||||
{line.line_code}
|
||||
{line.nucleo_name && <span className="line-nucleo"> ({line.nucleo_name})</span>}
|
||||
@@ -185,6 +193,510 @@ function LinesTable({ lines }) {
|
||||
);
|
||||
}
|
||||
|
||||
// View mode tabs
|
||||
function ViewTabs({ viewMode, onChangeView, onCompareClick }) {
|
||||
return (
|
||||
<div className="view-tabs">
|
||||
<button
|
||||
className={`view-tab ${viewMode === VIEW_MODES.GENERAL ? 'active' : ''}`}
|
||||
onClick={() => onChangeView(VIEW_MODES.GENERAL)}
|
||||
>
|
||||
<BarChart3 size={16} />
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${viewMode === VIEW_MODES.LINE ? 'active' : ''}`}
|
||||
onClick={() => onChangeView(VIEW_MODES.LINE)}
|
||||
>
|
||||
<Train size={16} />
|
||||
Por Linea
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${viewMode === VIEW_MODES.REGION ? 'active' : ''}`}
|
||||
onClick={() => onChangeView(VIEW_MODES.REGION)}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
Por Region
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${viewMode === VIEW_MODES.COMPARE ? 'active' : ''}`}
|
||||
onClick={onCompareClick}
|
||||
>
|
||||
<GitCompare size={16} />
|
||||
Comparar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="selector-container">
|
||||
<button className="selector-button" onClick={() => setIsOpen(!isOpen)}>
|
||||
<Train size={16} />
|
||||
{selectedLabel}
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="selector-dropdown">
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||
className="selector-option"
|
||||
onClick={() => {
|
||||
onSelect(line.line_code, line.nucleo);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="line-code">{line.line_code}</span>
|
||||
<span className="line-nucleo">{line.nucleo_name}</span>
|
||||
<span className="line-trains">{line.unique_trains} trenes</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="selector-container">
|
||||
<button className="selector-button" onClick={() => setIsOpen(!isOpen)}>
|
||||
<MapPin size={16} />
|
||||
{selectedLabel}
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="selector-dropdown">
|
||||
{regions.map((region) => (
|
||||
<div
|
||||
key={region.nucleo}
|
||||
className="selector-option"
|
||||
onClick={() => {
|
||||
onSelect(region.nucleo);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="region-name">{region.nucleo_name}</span>
|
||||
<span className="region-lines">{region.line_count} lineas</span>
|
||||
<span className="region-trains">{region.unique_trains} trenes</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Line detail view
|
||||
function LineDetailView({ lineDetails, onBack }) {
|
||||
if (!lineDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
<div className="detail-header">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<ArrowLeft size={20} />
|
||||
Volver
|
||||
</button>
|
||||
<h2>
|
||||
<Train size={24} />
|
||||
Linea {lineDetails.line_code}
|
||||
<span className="detail-subtitle">{lineDetails.nucleo_name}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="stats-row">
|
||||
<StatCard
|
||||
icon={Train}
|
||||
label="Trenes Unicos"
|
||||
value={lineDetails.stats.unique_trains}
|
||||
color="#3498DB"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle}
|
||||
label="Puntualidad"
|
||||
value={`${lineDetails.stats.punctuality_pct}%`}
|
||||
color="#27AE60"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Retraso Medio"
|
||||
value={`${lineDetails.stats.avg_delay.toFixed(1)} min`}
|
||||
color={lineDetails.stats.avg_delay > 5 ? '#E74C3C' : '#27AE60'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Retraso Maximo"
|
||||
value={`${lineDetails.stats.max_delay} min`}
|
||||
color="#E74C3C"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-card">
|
||||
<h3>
|
||||
<Clock size={18} />
|
||||
Distribucion de Puntualidad
|
||||
</h3>
|
||||
<PunctualityDonut data={lineDetails.stats.punctuality_breakdown} />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card wide">
|
||||
<h3>
|
||||
<TrendingUp size={18} />
|
||||
Evolucion Temporal
|
||||
</h3>
|
||||
<div className="timeline-charts">
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Trenes</span>
|
||||
<MiniChart data={lineDetails.timeline} dataKey="train_count" color="#3498DB" height={50} />
|
||||
</div>
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Puntualidad %</span>
|
||||
<MiniChart data={lineDetails.timeline} dataKey="punctuality_pct" color="#27AE60" height={50} />
|
||||
</div>
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Retraso Medio</span>
|
||||
<MiniChart data={lineDetails.timeline} dataKey="avg_delay" color="#E74C3C" height={50} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lineDetails.top_stations && lineDetails.top_stations.length > 0 && (
|
||||
<div className="dashboard-card">
|
||||
<h3>
|
||||
<MapPin size={18} />
|
||||
Estaciones Principales
|
||||
</h3>
|
||||
<div className="stations-list">
|
||||
{lineDetails.top_stations.map((station, index) => (
|
||||
<div key={station.station_code} className="station-item">
|
||||
<span className="station-rank">#{index + 1}</span>
|
||||
<span className="station-code">{station.station_code}</span>
|
||||
<span className="station-trains">{station.train_count} trenes</span>
|
||||
<span
|
||||
className="station-delay"
|
||||
style={{ color: station.avg_delay > 5 ? '#E74C3C' : '#27AE60' }}
|
||||
>
|
||||
{station.avg_delay.toFixed(1)} min
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Region detail view
|
||||
function RegionDetailView({ regionDetails, onBack, onLineClick }) {
|
||||
if (!regionDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
<div className="detail-header">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<ArrowLeft size={20} />
|
||||
Volver
|
||||
</button>
|
||||
<h2>
|
||||
<MapPin size={24} />
|
||||
{regionDetails.nucleo_name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="stats-row">
|
||||
<StatCard
|
||||
icon={BarChart3}
|
||||
label="Lineas"
|
||||
value={regionDetails.stats.line_count}
|
||||
color="#9B59B6"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Train}
|
||||
label="Trenes Unicos"
|
||||
value={regionDetails.stats.unique_trains}
|
||||
color="#3498DB"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle}
|
||||
label="Puntualidad"
|
||||
value={`${regionDetails.stats.punctuality_pct}%`}
|
||||
color="#27AE60"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Retraso Medio"
|
||||
value={`${regionDetails.stats.avg_delay.toFixed(1)} min`}
|
||||
color={regionDetails.stats.avg_delay > 5 ? '#E74C3C' : '#27AE60'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-card">
|
||||
<h3>
|
||||
<Clock size={18} />
|
||||
Distribucion de Puntualidad
|
||||
</h3>
|
||||
<PunctualityDonut data={regionDetails.stats.punctuality_breakdown} />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card wide">
|
||||
<h3>
|
||||
<TrendingUp size={18} />
|
||||
Evolucion Temporal
|
||||
</h3>
|
||||
<div className="timeline-charts">
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Trenes</span>
|
||||
<MiniChart data={regionDetails.timeline} dataKey="train_count" color="#3498DB" height={50} />
|
||||
</div>
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Puntualidad %</span>
|
||||
<MiniChart data={regionDetails.timeline} dataKey="punctuality_pct" color="#27AE60" height={50} />
|
||||
</div>
|
||||
<div className="timeline-chart">
|
||||
<span className="chart-label">Retraso Medio</span>
|
||||
<MiniChart data={regionDetails.timeline} dataKey="avg_delay" color="#E74C3C" height={50} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<h3>
|
||||
<Train size={18} />
|
||||
Lineas en la Region
|
||||
</h3>
|
||||
<div className="region-lines-list">
|
||||
{regionDetails.lines.map((line) => (
|
||||
<div
|
||||
key={line.line_code}
|
||||
className="region-line-item clickable"
|
||||
onClick={() => onLineClick(line.line_code, regionDetails.nucleo)}
|
||||
>
|
||||
<span className="line-code">{line.line_code}</span>
|
||||
<span className="line-trains">{line.unique_trains} trenes</span>
|
||||
<span
|
||||
className="line-punctuality"
|
||||
style={{ color: line.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}
|
||||
>
|
||||
{line.punctuality_pct}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Date range picker
|
||||
function DateRangePicker({ label, range, onChange }) {
|
||||
const formatDateForInput = (date) => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="date-range-picker">
|
||||
<label>{label}</label>
|
||||
<div className="date-range-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formatDateForInput(range.start)}
|
||||
onChange={(e) => onChange({ ...range, start: new Date(e.target.value) })}
|
||||
/>
|
||||
<span>a</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formatDateForInput(range.end)}
|
||||
onChange={(e) => onChange({ ...range, end: new Date(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <span className="diff-neutral">-</span>;
|
||||
const isPositive = diff > 0;
|
||||
const isGood = isPositiveGood ? isPositive : !isPositive;
|
||||
return (
|
||||
<span className={`diff-value ${isGood ? 'positive' : 'negative'}`}>
|
||||
{isPositive ? '+' : ''}{typeof diff === 'number' ? diff.toFixed(1) : diff}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
<div className="detail-header">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<ArrowLeft size={20} />
|
||||
Volver
|
||||
</button>
|
||||
<h2>
|
||||
<GitCompare size={24} />
|
||||
Comparacion de Periodos
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="compare-ranges-info">
|
||||
<div className="compare-range-label">
|
||||
<strong>Periodo 1:</strong> {formatDate(range1.start)} - {formatDate(range1.end)}
|
||||
</div>
|
||||
<div className="compare-range-label">
|
||||
<strong>Periodo 2:</strong> {formatDate(range2.start)} - {formatDate(range2.end)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-grid">
|
||||
<div className="compare-card">
|
||||
<h3>Trenes Unicos</h3>
|
||||
<div className="compare-values">
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 1</span>
|
||||
<span className="compare-number">{range1.stats.unique_trains}</span>
|
||||
</div>
|
||||
<div className="compare-diff">
|
||||
{renderDiff(comparison.unique_trains_diff, true)}
|
||||
{comparison.unique_trains_pct_change && (
|
||||
<span className="pct-change">({comparison.unique_trains_pct_change}%)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 2</span>
|
||||
<span className="compare-number">{range2.stats.unique_trains}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-card">
|
||||
<h3>Puntualidad</h3>
|
||||
<div className="compare-values">
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 1</span>
|
||||
<span className="compare-number">{range1.stats.punctuality_pct}%</span>
|
||||
</div>
|
||||
<div className="compare-diff">
|
||||
{renderDiff(comparison.punctuality_diff, true)}
|
||||
{comparison.punctuality_pct_change && (
|
||||
<span className="pct-change">({comparison.punctuality_pct_change}%)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 2</span>
|
||||
<span className="compare-number">{range2.stats.punctuality_pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-card">
|
||||
<h3>Retraso Medio</h3>
|
||||
<div className="compare-values">
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 1</span>
|
||||
<span className="compare-number">{range1.stats.avg_delay.toFixed(1)} min</span>
|
||||
</div>
|
||||
<div className="compare-diff">
|
||||
{renderDiff(comparison.avg_delay_diff, false)}
|
||||
{comparison.avg_delay_pct_change && (
|
||||
<span className="pct-change">({comparison.avg_delay_pct_change}%)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="compare-value">
|
||||
<span className="compare-label">Periodo 2</span>
|
||||
<span className="compare-number">{range2.stats.avg_delay.toFixed(1)} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-card wide">
|
||||
<h3>Distribucion de Puntualidad</h3>
|
||||
<div className="compare-punctuality">
|
||||
<div className="compare-punctuality-side">
|
||||
<h4>Periodo 1</h4>
|
||||
<PunctualityDonut data={range1.stats.punctuality_breakdown} />
|
||||
</div>
|
||||
<div className="compare-punctuality-side">
|
||||
<h4>Periodo 2</h4>
|
||||
<PunctualityDonut data={range2.stats.punctuality_breakdown} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="compare-setup">
|
||||
<h3>
|
||||
<GitCompare size={20} />
|
||||
Configurar Comparacion
|
||||
</h3>
|
||||
<DateRangePicker label="Periodo 1 (actual)" range={range1} onChange={setRange1} />
|
||||
<DateRangePicker label="Periodo 2 (anterior)" range={range2} onChange={setRange2} />
|
||||
<div className="compare-setup-actions">
|
||||
<button className="btn-secondary" onClick={onCancel}>Cancelar</button>
|
||||
<button className="btn-primary" onClick={handleSubmit}>Comparar</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="dashboard-error">
|
||||
@@ -300,8 +847,186 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// Render compare setup modal
|
||||
if (showCompareSetup) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<CompareSetup
|
||||
availableRange={availableRange}
|
||||
onStartCompare={handleStartCompare}
|
||||
onCancel={() => setShowCompareSetup(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render line detail view
|
||||
if (viewMode === VIEW_MODES.LINE && lineDetails) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<LineDetailView lineDetails={lineDetails} onBack={goToGeneral} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render region detail view
|
||||
if (viewMode === VIEW_MODES.REGION && regionDetails) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<RegionDetailView
|
||||
regionDetails={regionDetails}
|
||||
onBack={goToGeneral}
|
||||
onLineClick={selectLine}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render compare view
|
||||
if (viewMode === VIEW_MODES.COMPARE && compareData) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<CompareView
|
||||
compareData={compareData}
|
||||
onBack={goToGeneral}
|
||||
availableRange={availableRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render line selection view
|
||||
if (viewMode === VIEW_MODES.LINE && !lineDetails) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<div className="selection-view">
|
||||
<h2>
|
||||
<Train size={24} />
|
||||
Seleccionar Linea
|
||||
</h2>
|
||||
<LineSelector
|
||||
lines={allLines}
|
||||
selectedLine={selectedLine}
|
||||
onSelect={selectLine}
|
||||
/>
|
||||
{allLines.length > 0 && (
|
||||
<div className="selection-grid">
|
||||
{allLines.slice(0, 12).map((line, index) => (
|
||||
<div
|
||||
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||
className="selection-card clickable"
|
||||
onClick={() => selectLine(line.line_code, line.nucleo)}
|
||||
>
|
||||
<div className="selection-card-header">
|
||||
<span className="line-code">{line.line_code}</span>
|
||||
<span className="line-nucleo">{line.nucleo_name}</span>
|
||||
</div>
|
||||
<div className="selection-card-stats">
|
||||
<div className="mini-stat">
|
||||
<Train size={14} />
|
||||
{line.unique_trains} trenes
|
||||
</div>
|
||||
<div className="mini-stat" style={{ color: line.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}>
|
||||
<CheckCircle size={14} />
|
||||
{line.punctuality_pct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render region selection view
|
||||
if (viewMode === VIEW_MODES.REGION && !regionDetails) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
<div className="selection-view">
|
||||
<h2>
|
||||
<MapPin size={24} />
|
||||
Seleccionar Region
|
||||
</h2>
|
||||
<RegionSelector
|
||||
regions={allRegions}
|
||||
selectedRegion={selectedRegion}
|
||||
onSelect={selectRegion}
|
||||
/>
|
||||
{allRegions.length > 0 && (
|
||||
<div className="selection-grid">
|
||||
{allRegions.map((region) => (
|
||||
<div
|
||||
key={region.nucleo}
|
||||
className="selection-card clickable"
|
||||
onClick={() => selectRegion(region.nucleo)}
|
||||
>
|
||||
<div className="selection-card-header">
|
||||
<span className="region-name">{region.nucleo_name}</span>
|
||||
</div>
|
||||
<div className="selection-card-stats">
|
||||
<div className="mini-stat">
|
||||
<BarChart3 size={14} />
|
||||
{region.line_count} lineas
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<Train size={14} />
|
||||
{region.unique_trains} trenes
|
||||
</div>
|
||||
<div className="mini-stat" style={{ color: region.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}>
|
||||
<CheckCircle size={14} />
|
||||
{region.punctuality_pct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render general view (default)
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<ViewTabs
|
||||
viewMode={viewMode}
|
||||
onChangeView={handleViewChange}
|
||||
onCompareClick={handleCompareClick}
|
||||
/>
|
||||
|
||||
<TimeControl
|
||||
currentTime={currentTime}
|
||||
isLive={isLive}
|
||||
@@ -437,7 +1162,7 @@ export function Dashboard() {
|
||||
<AlertTriangle size={18} />
|
||||
Ranking de Lineas (Peor Puntualidad)
|
||||
</h3>
|
||||
<LinesTable lines={linesRanking} />
|
||||
<LinesTable lines={linesRanking} onLineClick={selectLine} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user