2 Commits

Author SHA1 Message Date
Millaguie
f979ad67a0 feat: improve dashboard
All checks were successful
Auto Tag on Merge to Main / auto-tag (push) Successful in 3s
CI - Lint and Build / lint-backend (push) Successful in 17s
CI - Lint and Build / lint-frontend (push) Successful in 12s
CI - Lint and Build / build-frontend (push) Successful in 16s
CI - Lint and Build / docker-build-test (push) Successful in 36s
2025-11-28 15:03:22 +01:00
Millaguie
436a7e25d4 fix(ci): use REGISTRY secrets for container registry login
Some checks failed
Auto Tag on Merge to Main / auto-tag (push) Failing after 2s
CI - Lint and Build / build-frontend (push) Successful in 14s
Release - Build and Publish Docker Images / build-and-publish (push) Successful in 1m9s
CI - Lint and Build / lint-backend (push) Successful in 14s
CI - Lint and Build / lint-frontend (push) Successful in 13s
CI - Lint and Build / docker-build-test (push) Successful in 36s
GITHUB_TOKEN doesn't have container registry permissions in Gitea.
Use dedicated REGISTRY_USERNAME and REGISTRY_PASSWORD secrets.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 00:43:42 +01:00
5 changed files with 1850 additions and 8 deletions

View File

@@ -30,8 +30,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: tea.millaguie.net registry: tea.millaguie.net
username: ${{ github.actor }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push backend image - name: Build and push backend image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5

View File

@@ -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; export default router;

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
BarChart3, BarChart3,
Clock, Clock,
@@ -12,8 +12,12 @@ import {
SkipForward, SkipForward,
Radio, Radio,
Calendar, Calendar,
MapPin,
ArrowLeft,
GitCompare,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { useDashboard } from '../hooks/useDashboard'; import { useDashboard, VIEW_MODES } from '../hooks/useDashboard';
// Mini chart component for timeline // Mini chart component for timeline
function MiniChart({ data, dataKey, color, height = 60 }) { function MiniChart({ data, dataKey, color, height = 60 }) {
@@ -153,7 +157,7 @@ function PunctualityDonut({ data }) {
} }
// Lines ranking table // Lines ranking table
function LinesTable({ lines }) { function LinesTable({ lines, onLineClick }) {
if (!lines || lines.length === 0) { if (!lines || lines.length === 0) {
return <div className="empty-state">Sin datos de lineas</div>; return <div className="empty-state">Sin datos de lineas</div>;
} }
@@ -167,7 +171,11 @@ function LinesTable({ lines }) {
<span>Puntualidad</span> <span>Puntualidad</span>
</div> </div>
{lines.slice(0, 10).map((line, index) => ( {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"> <span className="line-code">
{line.line_code} {line.line_code}
{line.nucleo_name && <span className="line-nucleo"> ({line.nucleo_name})</span>} {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 // Time control bar
function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) { function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) {
const formatTime = (date) => { const formatTime = (date) => {
@@ -282,14 +794,49 @@ export function Dashboard() {
seekTo, seekTo,
goLive, goLive,
skip, skip,
// New view states
viewMode,
setViewMode,
allLines,
allRegions,
selectedLine,
selectedRegion,
lineDetails,
regionDetails,
compareData,
// New view actions
selectLine,
selectRegion,
startCompare,
goToGeneral,
} = useDashboard(); } = useDashboard();
const [showCompareSetup, setShowCompareSetup] = useState(false);
// Calculate status totals // Calculate status totals
const statusTotal = useMemo(() => { const statusTotal = useMemo(() => {
if (!stats?.status_breakdown) return 0; if (!stats?.status_breakdown) return 0;
return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0); return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0);
}, [stats]); }, [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) { if (error) {
return ( return (
<div className="dashboard-error"> <div className="dashboard-error">
@@ -300,8 +847,186 @@ export function Dashboard() {
); );
} }
// Render compare setup modal
if (showCompareSetup) {
return ( return (
<div className="dashboard"> <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 <TimeControl
currentTime={currentTime} currentTime={currentTime}
isLive={isLive} isLive={isLive}
@@ -437,7 +1162,7 @@ export function Dashboard() {
<AlertTriangle size={18} /> <AlertTriangle size={18} />
Ranking de Lineas (Peor Puntualidad) Ranking de Lineas (Peor Puntualidad)
</h3> </h3>
<LinesTable lines={linesRanking} /> <LinesTable lines={linesRanking} onLineClick={selectLine} />
</div> </div>
</div> </div>
</> </>

View File

@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; 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() { export function useDashboard() {
const [isLive, setIsLive] = useState(true); const [isLive, setIsLive] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
@@ -12,6 +20,20 @@ export function useDashboard() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); 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); const refreshIntervalRef = useRef(null);
// Fetch available data range // 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 // Seek to specific time
const seekTo = useCallback((timestamp) => { const seekTo = useCallback((timestamp) => {
setIsLive(false); setIsLive(false);
@@ -137,11 +276,13 @@ export function useDashboard() {
const start = new Date(now.getTime() - 3600000); const start = new Date(now.getTime() - 3600000);
await fetchTimeline(start, now, 5); await fetchTimeline(start, now, 5);
await fetchLinesRanking(now, 24); await fetchLinesRanking(now, 24);
await fetchAllLines(24);
await fetchAllRegions(24);
setIsLoading(false); setIsLoading(false);
}; };
init(); init();
}, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking]); }, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking, fetchAllLines, fetchAllRegions]);
// Auto-refresh when live // Auto-refresh when live
useEffect(() => { useEffect(() => {
@@ -167,6 +308,7 @@ export function useDashboard() {
}, [isLive, fetchCurrentStats, fetchTimeline]); }, [isLive, fetchCurrentStats, fetchTimeline]);
return { return {
// Original state
isLive, isLive,
currentTime, currentTime,
stats, stats,
@@ -179,5 +321,23 @@ export function useDashboard() {
goLive, goLive,
skip, skip,
setIsLive, setIsLive,
// New view states
viewMode,
setViewMode,
allLines,
allRegions,
selectedLine,
selectedRegion,
lineDetails,
regionDetails,
compareData,
compareRanges,
// New view actions
selectLine,
selectRegion,
startCompare,
goToGeneral,
fetchAllLines,
fetchAllRegions,
}; };
} }

View File

@@ -835,6 +835,522 @@ body {
font-size: 0.8em; 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 */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
@@ -854,4 +1370,21 @@ body {
.timeline-container { .timeline-container {
right: 20px; 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;
}
} }