aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/TimetableTable.css187
-rw-r--r--src/frontend/app/components/TimetableTable.tsx167
2 files changed, 354 insertions, 0 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>
+ );
+};