Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093b75d775 | ||
|
|
f979ad67a0 | ||
|
|
436a7e25d4 | ||
|
|
24a08e405f | ||
|
|
c9e6c1970c |
@@ -20,13 +20,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
cd backend
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
- name: Run ESLint (backend)
|
||||
run: |
|
||||
@@ -48,13 +46,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
- name: Run ESLint (frontend)
|
||||
run: |
|
||||
@@ -72,13 +68,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
|
||||
@@ -29,9 +29,9 @@ jobs:
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ github.server_url }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: tea.millaguie.net
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -52,10 +52,11 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
target: production
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_API_URL=${{ secrets.PROD_API_URL }}
|
||||
VITE_WS_URL=${{ secrets.PROD_WS_URL }}
|
||||
VITE_API_URL=${{ secrets.PROD_API_URL || 'https://trenes.millaguie.net/api' }}
|
||||
VITE_WS_URL=${{ secrets.PROD_WS_URL || 'https://trenes.millaguie.net' }}
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.date.outputs.date }}
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
|
||||
@@ -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,17 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
# Docker Compose para producción
|
||||
# Uso: docker compose -f docker-compose.prod.yml up -d --build
|
||||
# Uso: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Requisitos previos:
|
||||
# 1. Configurar .env con valores de producción (ver .env.example)
|
||||
# 2. Configurar certificados SSL en ./nginx/ssl/ o usar certbot
|
||||
# 2. Login al registry: docker login tea.millaguie.net
|
||||
# 3. Configurar nginx/prod.conf con tu dominio
|
||||
|
||||
services:
|
||||
# Base de datos PostgreSQL con extensión PostGIS
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4-alpine # IMPORTANTE: usar versión 16 para compatibilidad
|
||||
image: postgis/postgis:16-3.4-alpine
|
||||
container_name: trenes-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -68,17 +66,42 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- trenes-network
|
||||
profiles:
|
||||
- migration
|
||||
|
||||
# API Backend
|
||||
api:
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-api
|
||||
restart: unless-stopped
|
||||
command: ["node", "src/api/server.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
REDIS_URL: redis://redis:6379
|
||||
CORS_ORIGIN: ${CORS_ORIGINS:-https://localhost}
|
||||
JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para polling GTFS-RT Vehicle Positions
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-worker
|
||||
restart: unless-stopped
|
||||
command: ["node", "src/worker/gtfs-poller.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
@@ -91,24 +114,23 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para sincronización GTFS Static
|
||||
gtfs-static-syncer:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-gtfs-static-syncer
|
||||
restart: unless-stopped
|
||||
command: node src/worker/gtfs-static-syncer.js
|
||||
command: ["node", "src/worker/gtfs-static-syncer.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
REDIS_URL: redis://redis:6379
|
||||
GTFS_STATIC_URL: https://data.renfe.com/dataset/horarios-trenes-largo-recorrido-ave/resource/horarios-trenes-largo-recorrido-ave-gtfs.zip
|
||||
SYNC_SCHEDULE: 0 3 * * *
|
||||
SYNC_SCHEDULE: "0 3 * * *"
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- gtfs_static_data:/tmp/gtfs
|
||||
@@ -117,18 +139,17 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para polling GTFS-RT Trip Updates
|
||||
trip-updates-poller:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-trip-updates-poller
|
||||
restart: unless-stopped
|
||||
command: node src/worker/trip-updates-poller.js
|
||||
command: ["node", "src/worker/trip-updates-poller.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
@@ -141,18 +162,17 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para polling GTFS-RT Service Alerts
|
||||
alerts-poller:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-alerts-poller
|
||||
restart: unless-stopped
|
||||
command: node src/worker/alerts-poller.js
|
||||
command: ["node", "src/worker/alerts-poller.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
@@ -165,18 +185,17 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para datos de flota Renfe
|
||||
renfe-fleet-poller:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-renfe-fleet-poller
|
||||
restart: unless-stopped
|
||||
command: node src/worker/renfe-fleet-poller.js
|
||||
command: ["node", "src/worker/renfe-fleet-poller.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
@@ -187,18 +206,17 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Worker para refrescar vistas de analytics
|
||||
analytics-refresher:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-analytics-refresher
|
||||
restart: unless-stopped
|
||||
command: node src/worker/analytics-refresher.js
|
||||
command: ["node", "src/worker/analytics-refresher.js"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
@@ -210,49 +228,14 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# API Backend
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: api
|
||||
container_name: trenes-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||
REDIS_URL: redis://redis:6379
|
||||
CORS_ORIGIN: ${CORS_ORIGINS:-https://localhost}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
flyway:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- trenes-network
|
||||
|
||||
# Frontend
|
||||
# IMPORTANTE: Las variables VITE_* deben pasarse como build args, no como environment
|
||||
# ya que se procesan en tiempo de compilación, no en runtime
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL}
|
||||
VITE_WS_URL: ${VITE_WS_URL}
|
||||
image: tea.millaguie.net/millaguie/trenes-frontend:${IMAGE_TAG:-latest}
|
||||
container_name: trenes-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -267,7 +250,7 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- letsencrypt_certs:/etc/letsencrypt:ro
|
||||
- certbot_certs:/etc/letsencrypt:ro
|
||||
- certbot_webroot:/var/www/certbot:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -288,7 +271,7 @@ services:
|
||||
image: certbot/certbot
|
||||
container_name: trenes-certbot
|
||||
volumes:
|
||||
- letsencrypt_certs:/etc/letsencrypt
|
||||
- certbot_certs:/etc/letsencrypt
|
||||
- certbot_webroot:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
networks:
|
||||
@@ -301,10 +284,10 @@ volumes:
|
||||
driver: local
|
||||
gtfs_static_data:
|
||||
driver: local
|
||||
letsencrypt_certs:
|
||||
driver: local
|
||||
certbot_certs:
|
||||
external: true
|
||||
certbot_webroot:
|
||||
driver: local
|
||||
external: true
|
||||
|
||||
networks:
|
||||
trenes-network:
|
||||
|
||||
@@ -68,8 +68,8 @@ function App() {
|
||||
{activeView === 'dashboard'
|
||||
? 'Dashboard de Trenes'
|
||||
: isTimelineMode
|
||||
? 'Reproduccion Historica - Espana'
|
||||
: 'Trenes en Tiempo Real - Espana'}
|
||||
? 'Reproduccion Historica - España'
|
||||
: 'Trenes en Tiempo Real - España'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '.
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Pre-calculate bearings for historical data based on consecutive positions
|
||||
* Pre-calculate bearings for historical data based on the last different position
|
||||
* Searches backwards to find a position with significant distance for accurate bearing
|
||||
* @param {Array} positions - Array of positions sorted by timestamp ASC
|
||||
* @returns {Array} - Positions with calculated bearings
|
||||
*/
|
||||
@@ -26,19 +27,43 @@ function calculateHistoricalBearings(positions) {
|
||||
// Sort by timestamp (should already be sorted, but ensure)
|
||||
trainPos.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
let lastSignificantPos = null; // Track last position with significant movement
|
||||
let lastBearing = null; // Track last calculated bearing
|
||||
|
||||
for (let i = 0; i < trainPos.length; i++) {
|
||||
const current = trainPos[i];
|
||||
let bearing = current.bearing; // Use existing bearing if available
|
||||
|
||||
if (bearing === null || bearing === undefined) {
|
||||
// Calculate bearing from previous position
|
||||
if (i > 0) {
|
||||
const prev = trainPos[i - 1];
|
||||
const distance = calculateDistance(prev.latitude, prev.longitude, current.latitude, current.longitude);
|
||||
// Search backwards for the last position with significant distance
|
||||
if (lastSignificantPos) {
|
||||
const distance = calculateDistance(
|
||||
lastSignificantPos.latitude,
|
||||
lastSignificantPos.longitude,
|
||||
current.latitude,
|
||||
current.longitude
|
||||
);
|
||||
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
||||
bearing = calculateBearing(prev.latitude, prev.longitude, current.latitude, current.longitude);
|
||||
bearing = calculateBearing(
|
||||
lastSignificantPos.latitude,
|
||||
lastSignificantPos.longitude,
|
||||
current.latitude,
|
||||
current.longitude
|
||||
);
|
||||
lastSignificantPos = current;
|
||||
lastBearing = bearing;
|
||||
} else {
|
||||
// No significant movement, keep the last known bearing
|
||||
bearing = lastBearing;
|
||||
}
|
||||
} else {
|
||||
// First position for this train
|
||||
lastSignificantPos = current;
|
||||
}
|
||||
} else {
|
||||
// Has bearing from API, update tracking
|
||||
lastSignificantPos = current;
|
||||
lastBearing = bearing;
|
||||
}
|
||||
|
||||
result.push({
|
||||
|
||||
@@ -6,28 +6,31 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
|
||||
|
||||
// Calculate bearing for trains based on previous positions
|
||||
// Only updates the stored position when there's significant movement
|
||||
function addCalculatedBearings(newTrains, previousPositions) {
|
||||
return newTrains.map(train => {
|
||||
// If train already has bearing from API, use it
|
||||
if (train.bearing !== null && train.bearing !== undefined) {
|
||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
|
||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: train.bearing });
|
||||
return train;
|
||||
}
|
||||
|
||||
const prevPos = previousPositions.get(train.train_id);
|
||||
let calculatedBearing = null;
|
||||
let calculatedBearing = prevPos?.bearing ?? null;
|
||||
|
||||
if (prevPos) {
|
||||
const distance = calculateDistance(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
||||
// Only calculate bearing if the train moved enough
|
||||
// Only calculate bearing and update position if the train moved enough
|
||||
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
||||
calculatedBearing = calculateBearing(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
||||
// Only update stored position when there's significant movement
|
||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: calculatedBearing });
|
||||
}
|
||||
} else {
|
||||
// First time seeing this train, store position without bearing
|
||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: null });
|
||||
}
|
||||
|
||||
// Update previous position
|
||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
|
||||
|
||||
return {
|
||||
...train,
|
||||
bearing: calculatedBearing,
|
||||
|
||||
@@ -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