aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/TimetableTable.css187
-rw-r--r--src/frontend/app/components/TimetableTable.tsx167
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json18
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json18
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json18
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/estimates-$id.css46
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx49
-rw-r--r--src/frontend/app/routes/timetable-$id.css139
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx275
10 files changed, 916 insertions, 2 deletions
diff --git a/src/frontend/app/components/TimetableTable.css b/src/frontend/app/components/TimetableTable.css
new file mode 100644
index 0000000..52bd9ae
--- /dev/null
+++ b/src/frontend/app/components/TimetableTable.css
@@ -0,0 +1,187 @@
+.timetable-container {
+ margin-top: 2rem;
+}
+
+.timetable-caption {
+ font-weight: bold;
+ margin-bottom: 1rem;
+ text-align: left;
+ font-size: 1.1rem;
+ color: var(--text-primary, #333);
+}
+
+.timetable-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.timetable-card {
+ background: var(--surface-future, #fff);
+ border: 1px solid var(--card-border, #e0e0e0);
+ border-radius: 10px;
+ padding: 1.25rem;
+ transition: background 0.2s ease, border 0.2s ease;
+}
+
+.timetable-card.timetable-past {
+ background: var(--surface-past, #f3f3f3);
+ color: var(--text-secondary, #aaa);
+ border: 1px solid #e0e0e0;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.line-info {
+ flex-shrink: 0;
+}
+
+.destination-info {
+ flex: 1;
+ text-align: left;
+ margin: 0 1rem;
+ color: var(--text-primary, #333);
+}
+
+.destination-info strong {
+ font-size: 0.95rem;
+}
+
+.timetable-card.timetable-past .destination-info {
+ color: var(--text-secondary, #aaa);
+}
+
+.time-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex-shrink: 0;
+}
+
+.departure-time {
+ font-weight: bold;
+ font-family: monospace;
+ font-size: 1.1rem;
+ color: var(--text-primary, #333);
+}
+
+.timetable-card.timetable-past .departure-time {
+ color: var(--text-secondary, #aaa);
+}
+
+.card-body {
+ line-height: 1.4;
+}
+
+.route-streets {
+ font-size: 0.85rem;
+ color: var(--text-secondary, #666);
+ line-height: 1.8;
+ word-break: break-word;
+}
+
+.service-id {
+ font-family: monospace;
+ font-size: 0.8rem;
+ color: var(--text-secondary, #666);
+ background: var(--service-background, #f0f0f0);
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ font-weight: 500;
+ display: inline;
+ margin-right: 0.2em;
+}
+
+.timetable-card.timetable-past .service-id {
+ color: var(--text-secondary, #bbb);
+ background: #e8e8e8;
+}
+
+.no-data {
+ text-align: center;
+ color: var(--text-secondary, #666);
+ font-style: italic;
+ padding: 2rem;
+ background: var(--card-background, #f8f9fa);
+ border-radius: 8px;
+ border: 1px solid var(--card-border, #e0e0e0);
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .timetable-cards {
+ gap: 0.5rem;
+ }
+ .timetable-card {
+ padding: 0.75rem;
+ }
+ .card-header {
+ margin-bottom: 0.5rem;
+ }
+ .destination-info {
+ margin: 0 0.5rem;
+ }
+ .destination-info strong {
+ font-size: 0.9rem;
+ }
+ .departure-time {
+ font-size: 1rem;
+ }
+ .service-id {
+ font-size: 0.8rem;
+ padding: 0.2rem 0.4rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .card-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.5rem;
+ }
+
+ .destination-info {
+ text-align: left;
+ margin: 0;
+ order: 2;
+ }
+
+ .time-info {
+ align-items: flex-start;
+ order: 1;
+ align-self: flex-end;
+ }
+
+ .line-info {
+ order: 0;
+ align-self: flex-start;
+ }
+
+ /* Create a flex container for line and time on mobile */
+ .card-header {
+ position: relative;
+ }
+
+ .line-info {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+
+ .time-info {
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+
+ .destination-info {
+ margin-top: 2rem;
+ text-align: left;
+ }
+}
diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx
new file mode 100644
index 0000000..98360bc
--- /dev/null
+++ b/src/frontend/app/components/TimetableTable.tsx
@@ -0,0 +1,167 @@
+import { useTranslation } from "react-i18next";
+import LineIcon from "./LineIcon";
+import "./TimetableTable.css";
+
+export interface TimetableEntry {
+ line: {
+ name: string;
+ colour: string;
+ };
+ trip: {
+ id: string;
+ service_id: string;
+ headsign: string;
+ direction_id: number;
+ };
+ route_id: string;
+ departure_time: string;
+ arrival_time: string;
+ stop_sequence: number;
+ shape_dist_traveled: number;
+ next_streets: string[];
+}
+
+interface TimetableTableProps {
+ data: TimetableEntry[];
+ showAll?: boolean;
+ currentTime?: string; // HH:MM:SS format
+}
+
+// Utility function to parse service ID and get the turn number
+const parseServiceId = (serviceId: string): string => {
+ const parts = serviceId.split('_');
+ if (parts.length === 0) return '';
+
+ const lastPart = parts[parts.length - 1];
+ if (lastPart.length < 6) return '';
+
+ const last6 = lastPart.slice(-6);
+ const lineCode = last6.slice(0, 3);
+ const turnCode = last6.slice(-3);
+
+ // Remove leading zeros from turn
+ const turnNumber = parseInt(turnCode, 10).toString();
+
+ // Parse line number with special cases
+ const lineNumber = parseInt(lineCode, 10);
+ let displayLine: string;
+
+ switch (lineNumber) {
+ case 1: displayLine = "C1"; break;
+ case 3: displayLine = "C3"; break;
+ case 30: displayLine = "N1"; break;
+ case 33: displayLine = "N4"; break;
+ case 8: displayLine = "A"; break;
+ case 101: displayLine = "H"; break;
+ case 150: displayLine = "REF"; break;
+ case 500: displayLine = "TUR"; break;
+ default: displayLine = `L${lineNumber}`;
+ }
+
+ return `${displayLine}-${turnNumber}`;
+};
+
+// Utility function to compare times
+const timeToMinutes = (time: string): number => {
+ const [hours, minutes] = time.split(':').map(Number);
+ return hours * 60 + minutes;
+};
+
+// Utility function to find nearby entries
+const findNearbyEntries = (entries: TimetableEntry[], currentTime: string, before: number = 4, after: number = 4): TimetableEntry[] => {
+ if (!currentTime) return entries.slice(0, before + after);
+
+ const currentMinutes = timeToMinutes(currentTime);
+ const sortedEntries = [...entries].sort((a, b) =>
+ timeToMinutes(a.departure_time) - timeToMinutes(b.departure_time)
+ );
+
+ let currentIndex = sortedEntries.findIndex(entry =>
+ timeToMinutes(entry.departure_time) >= currentMinutes
+ );
+
+ if (currentIndex === -1) {
+ // All entries are before current time, show last ones
+ return sortedEntries.slice(-before - after);
+ }
+
+ const startIndex = Math.max(0, currentIndex - before);
+ const endIndex = Math.min(sortedEntries.length, currentIndex + after);
+
+ return sortedEntries.slice(startIndex, endIndex);
+};
+
+export const TimetableTable: React.FC<TimetableTableProps> = ({
+ data,
+ showAll = false,
+ currentTime
+}) => {
+ const { t } = useTranslation();
+
+ const displayData = showAll ? data : findNearbyEntries(data, currentTime || '');
+ const nowMinutes = currentTime ? timeToMinutes(currentTime) : timeToMinutes(new Date().toTimeString().slice(0, 8));
+
+ return (
+ <div className="timetable-container">
+ <div className="timetable-caption">
+ {showAll
+ ? t("timetable.fullCaption", "Horarios teóricos de la parada")
+ : t("timetable.nearbyCaption", "Próximos horarios teóricos")
+ }
+ </div>
+
+ <div className="timetable-cards">
+ {displayData.map((entry, index) => {
+ const entryMinutes = timeToMinutes(entry.departure_time);
+ const isPast = entryMinutes < nowMinutes;
+ return (
+ <div
+ key={`${entry.trip.id}-${index}`}
+ className={`timetable-card${isPast ? " timetable-past" : ""}`}
+ style={{
+ background: isPast
+ ? "var(--surface-past, #f3f3f3)"
+ : "var(--surface-future, #fff)"
+ }}
+ >
+ <div className="card-header">
+ <div className="line-info">
+ <LineIcon line={entry.line.name} />
+ </div>
+
+ <div className="destination-info">
+ {entry.trip.headsign && entry.trip.headsign.trim() ? (
+ <strong>{entry.trip.headsign}</strong>
+ ) : (
+ <strong>{t("timetable.noDestination", "Línea")} {entry.line.name}</strong>
+ )}
+ </div>
+
+ <div className="time-info">
+ <span className="departure-time">
+ {entry.departure_time.slice(0, 5)}
+ </span>
+ </div>
+ </div>
+ <div className="card-body">
+ {!isPast && (
+ <div className="route-streets">
+ <span className="service-id">
+ {parseServiceId(entry.trip.service_id)}
+ </span>
+ {entry.next_streets.length > 0 && (
+ <span> — {entry.next_streets.join(' — ')}</span>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ {displayData.length === 0 && (
+ <p className="no-data">{t("timetable.noData", "No hay datos de horarios disponibles")}</p>
+ )}
+ </div>
+ );
+};
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 264290b..e51310e 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -46,6 +46,24 @@
"none": "No estimates available",
"next_arrivals": "Next arrivals"
},
+ "timetable": {
+ "fullCaption": "Theoretical timetables for this stop",
+ "nearbyCaption": "Upcoming theoretical timetables",
+ "line": "Line",
+ "service": "Service",
+ "time": "Time",
+ "nextStreets": "Next streets",
+ "noData": "No timetable data available",
+ "noDestination": "Line",
+ "viewAll": "View all timetables",
+ "fullTitle": "Theoretical timetables",
+ "backToEstimates": "Back to estimates",
+ "noDataAvailable": "No timetable data available for today",
+ "loadError": "Error loading timetables",
+ "errorDetail": "Theoretical timetables are updated daily. Please try again later.",
+ "showPast": "Show all",
+ "hidePast": "Hide past"
+ },
"map": {
"popup_title": "Stop",
"lines": "Lines",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index d7d78ad..30eca41 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -46,6 +46,24 @@
"none": "No hay estimaciones disponibles",
"next_arrivals": "Próximas llegadas"
},
+ "timetable": {
+ "fullCaption": "Horarios teóricos de la parada",
+ "nearbyCaption": "Próximos horarios teóricos",
+ "line": "Línea",
+ "service": "Servicio",
+ "time": "Hora",
+ "nextStreets": "Próximas calles",
+ "noData": "No hay datos de horarios disponibles",
+ "noDestination": "Línea",
+ "viewAll": "Ver todos los horarios",
+ "fullTitle": "Horarios teóricos",
+ "backToEstimates": "Volver a estimaciones",
+ "noDataAvailable": "No hay datos de horarios disponibles para hoy",
+ "loadError": "Error al cargar los horarios",
+ "errorDetail": "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde.",
+ "showPast": "Mostrar todos",
+ "hidePast": "Ocultar pasados"
+ },
"map": {
"popup_title": "Parada",
"lines": "Líneas",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 3012638..756a106 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -46,6 +46,24 @@
"none": "Non hai estimacións dispoñibles",
"next_arrivals": "Próximas chegadas"
},
+ "timetable": {
+ "fullCaption": "Horarios teóricos da parada",
+ "nearbyCaption": "Próximos horarios teóricos",
+ "line": "Liña",
+ "service": "Servizo",
+ "time": "Hora",
+ "nextStreets": "Próximas rúas",
+ "noData": "Non hai datos de horarios dispoñibles",
+ "noDestination": "Liña",
+ "viewAll": "Ver todos os horarios",
+ "fullTitle": "Horarios teóricos",
+ "backToEstimates": "Volver a estimacións",
+ "noDataAvailable": "Non hai datos de horarios dispoñibles para hoxe",
+ "loadError": "Erro ao cargar os horarios",
+ "errorDetail": "Os horarios teóricos actualízanse diariamente. Inténtao máis tarde.",
+ "showPast": "Mostrar todos",
+ "hidePast": "Ocultar pasados"
+ },
"map": {
"popup_title": "Parada",
"lines": "Liñas",
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 9dd8a66..189949f 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -5,5 +5,6 @@ export default [
route("/stops", "routes/stoplist.tsx"),
route("/map", "routes/map.tsx"),
route("/estimates/:id", "routes/estimates-$id.tsx"),
+ route("/timetable/:id", "routes/timetable-$id.tsx"),
route("/settings", "routes/settings.tsx"),
] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
index 3905f3e..8906147 100644
--- a/src/frontend/app/routes/estimates-$id.css
+++ b/src/frontend/app/routes/estimates-$id.css
@@ -103,3 +103,49 @@
.edit-icon:hover {
color: var(--star-color);
}
+
+/* Timetable section styles */
+.timetable-section {
+ padding-top: 1.5rem;
+ padding-bottom: 3rem; /* Add bottom padding before footer */
+}
+
+/* Timetable cards should be single column */
+.timetable-section .timetable-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.timetable-section .timetable-card {
+ padding: 0.875rem;
+}
+
+.timetable-actions {
+ margin-top: 1.5rem;
+ text-align: center;
+}
+
+.view-all-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--link-color, #007bff);
+ text-decoration: none;
+ font-weight: 500;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--link-color, #007bff);
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.view-all-link:hover {
+ background-color: var(--link-color, #007bff);
+ color: white;
+ text-decoration: none;
+}
+
+.external-icon {
+ width: 1rem;
+ height: 1rem;
+}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index f2ef83a..b5ae91a 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -1,12 +1,13 @@
import { type JSX, useEffect, useState } from "react";
-import { useParams } from "react-router";
+import { useParams, Link } from "react-router";
import StopDataProvider from "../data/StopDataProvider";
-import { Star, Edit2 } from "lucide-react";
+import { Star, Edit2, ExternalLink } from "lucide-react";
import "./estimates-$id.css";
import { RegularTable } from "../components/RegularTable";
import { useApp } from "../AppContext";
import { GroupedTable } from "../components/GroupedTable";
import { useTranslation } from "react-i18next";
+import { TimetableTable, type TimetableEntry } from "../components/TimetableTable";
export interface StopDetails {
stop: {
@@ -32,6 +33,24 @@ const loadData = async (stopId: string) => {
return await resp.json();
};
+const loadTimetableData = async (stopId: string) => {
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
+ try {
+ const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ if (!resp.ok) {
+ throw new Error(`HTTP error! status: ${resp.status}`);
+ }
+ return await resp.json();
+ } catch (error) {
+ console.error('Error loading timetable data:', error);
+ return [];
+ }
+};
+
export default function Estimates() {
const { t } = useTranslation();
const params = useParams();
@@ -40,15 +59,22 @@ export default function Estimates() {
const [data, setData] = useState<StopDetails | null>(null);
const [dataDate, setDataDate] = useState<Date | null>(null);
const [favourited, setFavourited] = useState(false);
+ const [timetableData, setTimetableData] = useState<TimetableEntry[]>([]);
const { tableStyle } = useApp();
useEffect(() => {
+ // Load real-time estimates
loadData(params.id!).then((body: StopDetails) => {
setData(body);
setDataDate(new Date());
setCustomName(StopDataProvider.getCustomName(stopIdNum));
});
+ // Load timetable data
+ loadTimetableData(params.id!).then((timetableBody: TimetableEntry[]) => {
+ setTimetableData(timetableBody);
+ });
+
StopDataProvider.pushRecent(parseInt(params.id ?? ""));
setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? "")));
@@ -102,6 +128,25 @@ export default function Estimates() {
<RegularTable data={data} dataDate={dataDate} />
)}
</div>
+
+ <div className="timetable-section">
+ <TimetableTable
+ data={timetableData}
+ currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS
+ />
+
+ {timetableData.length > 0 && (
+ <div className="timetable-actions">
+ <Link
+ to={`/timetable/${params.id}`}
+ className="view-all-link"
+ >
+ <ExternalLink className="external-icon" />
+ {t("timetable.viewAll", "Ver todos los horarios")}
+ </Link>
+ </div>
+ )}
+ </div>
</div>
);
}
diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css
new file mode 100644
index 0000000..5ae472c
--- /dev/null
+++ b/src/frontend/app/routes/timetable-$id.css
@@ -0,0 +1,139 @@
+.timetable-full-header {
+ margin-bottom: 2rem;
+}
+
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--link-color, #007bff);
+ text-decoration: none;
+ margin-bottom: 1rem;
+ font-weight: 500;
+ transition: color 0.2s ease;
+}
+
+.back-link:hover {
+ color: var(--link-hover-color, #0056b3);
+ text-decoration: underline;
+}
+
+.back-icon {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.page-title .stop-name {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: var(--text-primary, #333);
+}
+
+.page-title .stop-id {
+ font-size: 1rem;
+ color: var(--text-secondary, #666);
+ font-weight: normal;
+ margin-left: 0.5rem;
+}
+
+.timetable-full-content {
+ margin-top: 1rem;
+}
+
+.error-message {
+ text-align: center;
+ padding: 3rem 2rem;
+ background-color: var(--error-background, #f8f9fa);
+ border: 1px solid var(--error-border, #dee2e6);
+ border-radius: 8px;
+ margin: 2rem 0;
+}
+
+.error-message p {
+ margin-bottom: 1rem;
+ color: var(--error-color, #dc3545);
+ font-weight: 500;
+}
+
+.error-detail {
+ font-size: 0.9rem;
+ color: var(--text-secondary, #666) !important;
+ font-weight: normal !important;
+}
+
+.timetable-controls {
+ margin-bottom: 1.5rem;
+ display: flex;
+ justify-content: center;
+}
+
+.past-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: var(--button-background, #f8f9fa);
+ color: var(--text-primary, #333);
+ border: 1px solid var(--button-border, #dee2e6);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.past-toggle:hover {
+ background-color: var(--button-hover-background, #e9ecef);
+}
+
+.past-toggle.active {
+ background-color: var(--link-color, #007bff);
+ color: white;
+ border-color: var(--link-color, #007bff);
+}
+
+.toggle-icon {
+ width: 1rem;
+ height: 1rem;
+}
+
+/* Next entry highlight */
+.timetable-card.timetable-next {
+ border: 2px solid var(--accent-color, #28a745);
+ background: var(--surface-next, #e8f5e8) !important;
+}
+
+/* Override timetable cards styles for full page */
+.timetable-full-content .timetable-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.timetable-full-content .timetable-caption {
+ font-size: 1.2rem;
+ margin-bottom: 1.5rem;
+}
+
+.timetable-full-content .timetable-card {
+ padding: 1.25rem;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .page-title {
+ font-size: 1.5rem;
+ }
+
+ .page-title .stop-name {
+ font-size: 1.1rem;
+ }
+
+ .timetable-full-content .timetable-cards {
+ gap: 0.75rem;
+ }
+
+ .timetable-full-content .timetable-card {
+ padding: 1rem;
+ }
+}
diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx
new file mode 100644
index 0000000..073dddb
--- /dev/null
+++ b/src/frontend/app/routes/timetable-$id.tsx
@@ -0,0 +1,275 @@
+import { useEffect, useState, useRef } from "react";
+import { useParams, Link } from "react-router";
+import StopDataProvider from "../data/StopDataProvider";
+import { ArrowLeft, Eye, EyeOff } from "lucide-react";
+import { TimetableTable, type TimetableEntry } from "../components/TimetableTable";
+import LineIcon from "../components/LineIcon";
+import { useTranslation } from "react-i18next";
+import "./timetable-$id.css";
+
+const loadTimetableData = async (stopId: string) => {
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
+ try {
+ const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ if (!resp.ok) {
+ throw new Error(`HTTP error! status: ${resp.status}`);
+ }
+ return await resp.json();
+ } catch (error) {
+ console.error('Error loading timetable data:', error);
+ return [];
+ }
+};
+
+// Utility function to compare times
+const timeToMinutes = (time: string): number => {
+ const [hours, minutes] = time.split(':').map(Number);
+ return hours * 60 + minutes;
+};
+
+// Filter past entries (keep only a few recent past ones)
+const filterTimetableData = (data: TimetableEntry[], currentTime: string, showPast: boolean = false): TimetableEntry[] => {
+ if (showPast) return data;
+
+ const currentMinutes = timeToMinutes(currentTime);
+ const sortedData = [...data].sort((a, b) =>
+ timeToMinutes(a.departure_time) - timeToMinutes(b.departure_time)
+ );
+
+ // Find the current position
+ const currentIndex = sortedData.findIndex(entry =>
+ timeToMinutes(entry.departure_time) >= currentMinutes
+ );
+
+ if (currentIndex === -1) {
+ // All entries are in the past, show last 3
+ return sortedData.slice(-3);
+ }
+
+ // Show 3 past entries + all future entries
+ const startIndex = Math.max(0, currentIndex - 3);
+ return sortedData.slice(startIndex);
+};
+
+// Utility function to parse service ID and get the turn number
+const parseServiceId = (serviceId: string): string => {
+ const parts = serviceId.split('_');
+ if (parts.length === 0) return '';
+
+ const lastPart = parts[parts.length - 1];
+ if (lastPart.length < 6) return '';
+
+ const last6 = lastPart.slice(-6);
+ const lineCode = last6.slice(0, 3);
+ const turnCode = last6.slice(-3);
+
+ // Remove leading zeros from turn
+ const turnNumber = parseInt(turnCode, 10).toString();
+
+ // Parse line number with special cases
+ const lineNumber = parseInt(lineCode, 10);
+ let displayLine: string;
+
+ switch (lineNumber) {
+ case 1: displayLine = "C1"; break;
+ case 3: displayLine = "C3"; break;
+ case 30: displayLine = "N1"; break;
+ case 33: displayLine = "N4"; break;
+ case 8: displayLine = "A"; break;
+ case 101: displayLine = "H"; break;
+ case 150: displayLine = "REF"; break;
+ case 500: displayLine = "TUR"; break;
+ default: displayLine = `L${lineNumber}`;
+ }
+
+ return `${displayLine}-${turnNumber}`;
+};
+
+export default function Timetable() {
+ const { t } = useTranslation();
+ const params = useParams();
+ const stopIdNum = parseInt(params.id ?? "");
+ const [timetableData, setTimetableData] = useState<TimetableEntry[]>([]);
+ const [customName, setCustomName] = useState<string | undefined>(undefined);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [showPastEntries, setShowPastEntries] = useState(false);
+ const nextEntryRef = useRef<HTMLDivElement>(null);
+
+ const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS
+ const filteredData = filterTimetableData(timetableData, currentTime, showPastEntries);
+
+ useEffect(() => {
+ loadTimetableData(params.id!).then((timetableBody: TimetableEntry[]) => {
+ setTimetableData(timetableBody);
+ setLoading(false);
+ if (timetableBody.length === 0) {
+ setError(t("timetable.noDataAvailable", "No hay datos de horarios disponibles para hoy"));
+ } else {
+ // Scroll to next entry after a short delay to allow rendering
+ setTimeout(() => {
+ const currentMinutes = timeToMinutes(currentTime);
+ const sortedData = [...timetableBody].sort((a, b) =>
+ timeToMinutes(a.departure_time) - timeToMinutes(b.departure_time)
+ );
+
+ const nextIndex = sortedData.findIndex(entry =>
+ timeToMinutes(entry.departure_time) >= currentMinutes
+ );
+
+ if (nextIndex !== -1 && nextEntryRef.current) {
+ nextEntryRef.current.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ }
+ }, 500);
+ }
+ }).catch((err) => {
+ setError(t("timetable.loadError", "Error al cargar los horarios"));
+ setLoading(false);
+ });
+
+ setCustomName(StopDataProvider.getCustomName(stopIdNum));
+ }, [params.id, stopIdNum, t, currentTime]);
+
+ if (loading) {
+ return <h1 className="page-title">{t("common.loading")}</h1>;
+ }
+
+ // Get stop name from timetable data or use stop ID
+ const stopName = customName ||
+ (timetableData.length > 0 ? `Parada ${params.id}` : `Parada ${params.id}`);
+
+ return (
+ <div className="page-container">
+ <div className="timetable-full-header">
+
+ <h1 className="page-title">
+ {t("timetable.fullTitle", "Horarios teóricos")} ({params.id})
+ </h1>
+ <Link to={`/estimates/${params.id}`} className="back-link">
+ <ArrowLeft className="back-icon" />
+ {t("timetable.backToEstimates", "Volver a estimaciones")}
+ </Link>
+ </div>
+
+ {error ? (
+ <div className="error-message">
+ <p>{error}</p>
+ <p className="error-detail">
+ {t("timetable.errorDetail", "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde.")}
+ </p>
+ </div>
+ ) : (
+ <div className="timetable-full-content">
+ <div className="timetable-controls">
+ <button
+ className={`past-toggle ${showPastEntries ? 'active' : ''}`}
+ onClick={() => setShowPastEntries(!showPastEntries)}
+ >
+ {showPastEntries ? (
+ <>
+ <EyeOff className="toggle-icon" />
+ {t("timetable.hidePast", "Ocultar pasados")}
+ </>
+ ) : (
+ <>
+ <Eye className="toggle-icon" />
+ {t("timetable.showPast", "Mostrar todos")}
+ </>
+ )}
+ </button>
+ </div>
+
+ <TimetableTableWithScroll
+ data={filteredData}
+ showAll={true}
+ currentTime={currentTime}
+ nextEntryRef={nextEntryRef}
+ />
+ </div>
+ )}
+ </div>
+ );
+}
+
+// Custom component for the full timetable with scroll reference
+const TimetableTableWithScroll: React.FC<{
+ data: TimetableEntry[];
+ showAll: boolean;
+ currentTime: string;
+ nextEntryRef: React.RefObject<HTMLDivElement | null>;
+}> = ({ data, showAll, currentTime, nextEntryRef }) => {
+ const { t } = useTranslation();
+ const nowMinutes = timeToMinutes(currentTime);
+
+ return (
+ <div className="timetable-container">
+ <div className="timetable-caption">
+ {t("timetable.fullCaption", "Horarios teóricos de la parada")}
+ </div>
+
+ <div className="timetable-cards">
+ {data.map((entry, index) => {
+ const entryMinutes = timeToMinutes(entry.departure_time);
+ const isPast = entryMinutes < nowMinutes;
+ const isNext = !isPast && (index === 0 || timeToMinutes(data[index - 1]?.departure_time || '00:00:00') < nowMinutes);
+
+ return (
+ <div
+ key={`${entry.trip.id}-${index}`}
+ ref={isNext ? nextEntryRef : null}
+ className={`timetable-card${isPast ? " timetable-past" : ""}${isNext ? " timetable-next" : ""}`}
+ style={{
+ background: isPast
+ ? "var(--surface-past, #f3f3f3)"
+ : isNext
+ ? "var(--surface-next, #e8f5e8)"
+ : "var(--surface-future, #fff)"
+ }}
+ >
+ <div className="card-header">
+ <div className="line-info">
+ <LineIcon line={entry.line.name} />
+ </div>
+
+ <div className="destination-info">
+ {entry.trip.headsign && entry.trip.headsign.trim() ? (
+ <strong>{entry.trip.headsign}</strong>
+ ) : (
+ <strong>{t("timetable.noDestination", "Línea")} {entry.line.name}</strong>
+ )}
+ </div>
+
+ <div className="time-info">
+ <span className="departure-time">
+ {entry.departure_time.slice(0, 5)}
+ </span>
+ <div className="service-id">
+ {parseServiceId(entry.trip.service_id)}
+ </div>
+ </div>
+ </div>
+ <div className="card-body">
+ {!isPast && entry.next_streets.length > 0 && (
+ <div className="route-streets">
+ {entry.next_streets.join(' — ')}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {data.length === 0 && (
+ <p className="no-data">{t("timetable.noData", "No hay datos de horarios disponibles")}</p>
+ )}
+ </div>
+ );
+};