aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/AppContext.tsx2
-rw-r--r--src/frontend/app/components/LineIcon.css7
-rw-r--r--src/frontend/app/components/SchedulesTable.css46
-rw-r--r--src/frontend/app/components/SchedulesTable.tsx6
-rw-r--r--src/frontend/app/components/StopSheet.tsx2
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css158
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx217
-rw-r--r--src/frontend/app/data/RegionConfig.ts3
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json17
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json17
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json17
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/estimates-$id.css33
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx13
-rw-r--r--src/frontend/app/routes/settings.css3
-rw-r--r--src/frontend/app/routes/settings.tsx36
-rw-r--r--src/frontend/app/routes/stops-$id.tsx262
-rw-r--r--src/frontend/tsconfig.json6
18 files changed, 791 insertions, 55 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx
index b986880..a369293 100644
--- a/src/frontend/app/AppContext.tsx
+++ b/src/frontend/app/AppContext.tsx
@@ -16,7 +16,7 @@ import {
} from "./data/RegionConfig";
export type Theme = "light" | "dark" | "system";
-type TableStyle = "regular" | "grouped";
+type TableStyle = "regular" | "grouped" | "experimental_consolidated";
type MapPositionMode = "gps" | "last";
interface MapState {
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index fe4a87f..0b93023 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -38,15 +38,20 @@
--line-vigo-l24: rgb(191, 191, 191);
--line-vigo-l25: rgb(172, 100, 4);
--line-vigo-l27: rgb(112, 74, 42);
+ --line-vigo-l27-text: #ffffff;
--line-vigo-l28: rgb(176, 189, 254);
--line-vigo-l29: rgb(248, 184, 90);
--line-vigo-l31: rgb(255, 255, 0);
--line-vigo-a: rgb(119, 41, 143);
--line-vigo-a-text: #ffffff;
--line-vigo-h: rgb(0, 96, 168);
+ --line-vigo-h-text: #ffffff;
--line-vigo-h1: rgb(0, 96, 168);
+ --line-vigo-h1-text: #ffffff;
--line-vigo-h2: rgb(0, 96, 168);
+ --line-vigo-h2-text: #ffffff;
--line-vigo-h3: rgb(0, 96, 168);
+ --line-vigo-h3-text: #ffffff;
--line-vigo-lzd: rgb(61, 78, 167);
--line-vigo-n1: rgb(191, 191, 191);
--line-vigo-n4: rgb(102, 51, 102);
@@ -56,7 +61,9 @@
--line-vigo-ptl: rgb(150, 220, 153);
--line-vigo-turistico: rgb(102, 51, 102);
--line-vigo-u1: rgb(172, 100, 4);
+ --line-vigo-u1-text: #ffffff;
--line-vigo-u2: rgb(172, 100, 4);
+ --line-vigo-u2-text: #ffffff;
--line-santiago-l1: #f32621;
--line-santiago-l4: #ffcc33;
diff --git a/src/frontend/app/components/SchedulesTable.css b/src/frontend/app/components/SchedulesTable.css
index 74d7569..c0c5cb7 100644
--- a/src/frontend/app/components/SchedulesTable.css
+++ b/src/frontend/app/components/SchedulesTable.css
@@ -52,25 +52,25 @@
border: 1px solid var(--card-border);
}
-.card-header {
+.timetable-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
-.line-info {
+.timetable-card .line-info {
flex-shrink: 0;
}
-.destination-info {
+.timetable-card .destination-info {
flex: 1;
text-align: left;
margin: 0 1rem;
color: var(--text-primary);
}
-.destination-info strong {
+.timetable-card .destination-info strong {
font-size: 0.95rem;
}
@@ -78,14 +78,14 @@
color: var(--text-secondary);
}
-.time-info {
+.timetable-card .time-info {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
-.departure-time {
+.timetable-card .timetable-card .departure-time {
font-weight: bold;
font-family: monospace;
font-size: 1.1rem;
@@ -96,18 +96,18 @@
color: var(--text-secondary);
}
-.card-body {
+.timetable-card .card-body {
line-height: 1.4;
}
-.route-streets {
+.timetable-card .route-streets {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.8;
word-break: break-word;
}
-.service-id {
+.timetable-card .timetable-card .service-id {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
@@ -124,7 +124,7 @@
background: var(--service-background-past);
}
-.no-data {
+.timetable-container .no-data {
text-align: center;
color: var(--text-secondary);
font-style: italic;
@@ -142,66 +142,66 @@
.timetable-card {
padding: 0.75rem;
}
- .card-header {
+ .timetable-card .card-header {
margin-bottom: 0.5rem;
}
- .destination-info {
+ .timetable-card .destination-info {
margin: 0 0.5rem;
}
- .destination-info strong {
+ .timetable-card .destination-info strong {
font-size: 0.9rem;
}
- .departure-time {
+ .timetable-card .departure-time {
font-size: 1rem;
}
- .service-id {
+ .timetable-card .service-id {
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
}
}
@media (max-width: 480px) {
- .card-header {
+ .timetable-card .card-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
- .destination-info {
+ .timetable-card .destination-info {
text-align: left;
margin: 0;
order: 2;
}
- .time-info {
+ .timetable-card .time-info {
align-items: flex-start;
order: 1;
align-self: flex-end;
}
- .line-info {
+ .timetable-card .line-info {
order: 0;
align-self: flex-start;
}
/* Create a flex container for line and time on mobile */
- .card-header {
+ .timetable-card .card-header {
position: relative;
}
- .line-info {
+ .timetable-card .line-info {
position: absolute;
left: 0;
top: 0;
}
- .time-info {
+ .timetable-card .time-info {
position: absolute;
right: 0;
top: 0;
}
- .destination-info {
+ .timetable-card .destination-info {
margin-top: 2rem;
text-align: left;
}
diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx
index 9f3f062..07d3136 100644
--- a/src/frontend/app/components/SchedulesTable.tsx
+++ b/src/frontend/app/components/SchedulesTable.tsx
@@ -73,6 +73,12 @@ const parseServiceId = (serviceId: string): string => {
case 150:
displayLine = "REF";
break;
+ case 201:
+ displayLine = "U1";
+ break;
+ case 202:
+ displayLine = "U2";
+ break;
case 500:
displayLine = "TUR";
break;
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
index 0c19cb6..2f37519 100644
--- a/src/frontend/app/components/StopSheet.tsx
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -140,7 +140,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
</div>
<div
- className={`stop-sheet-lines-container ${stop.lines.length >= 6 ? "scrollable" : ""}`}
+ className={`stop-sheet-lines-container ${stop.lines.length >= 10 ? "scrollable" : ""}`}
>
{stop.lines.map((line) => (
<div key={line} className="stop-sheet-line-icon">
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
new file mode 100644
index 0000000..65e897b
--- /dev/null
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
@@ -0,0 +1,158 @@
+/* Consolidated Circulation List Styles */
+.consolidated-circulation-container {
+ width: 100%;
+ margin-block-start: 1.5rem;
+}
+
+.consolidated-circulation-caption {
+ font-size: 0.9rem;
+ color: var(--subtitle-color);
+ text-align: center;
+ margin-bottom: 1rem;
+ padding: 0.5rem;
+}
+
+.consolidated-circulation-no-data {
+ text-align: center;
+ padding: 2rem 1rem;
+ color: var(--subtitle-color);
+ font-size: 0.95rem;
+}
+
+.consolidated-circulation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding-block: 0 1rem;
+}
+
+.consolidated-circulation-card {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--message-background-color);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ overflow: hidden;
+ transition: box-shadow 0.2s ease;
+}
+
+.consolidated-circulation-card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.consolidated-circulation-card .card-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+}
+
+.consolidated-circulation-card .line-info {
+ flex-shrink: 0;
+}
+
+.consolidated-circulation-card .route-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.consolidated-circulation-card .route-info strong {
+ font-size: 0.95rem;
+ color: var(--text-color);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+}
+
+.consolidated-circulation-card .time-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.consolidated-circulation-card .arrival-time {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 1.05rem;
+ font-weight: 600;
+}
+
+.consolidated-circulation-card .arrival-time svg {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+
+/* Time color states */
+.consolidated-circulation-card .arrival-time.time-running {
+ color: #22c55e;
+}
+
+.consolidated-circulation-card .arrival-time.time-running svg {
+ color: #22c55e;
+}
+
+.consolidated-circulation-card .arrival-time.time-delayed {
+ color: #09106e;
+}
+
+.consolidated-circulation-card .arrival-time.time-delayed svg {
+ color: #09106e;
+}
+
+.consolidated-circulation-card .arrival-time.time-scheduled {
+ color: var(--text-color);
+}
+
+.consolidated-circulation-card .arrival-time.time-scheduled svg {
+ color: var(--subtitle-color);
+}
+
+.consolidated-circulation-card .distance-info {
+ font-size: 0.75rem;
+ color: var(--subtitle-color);
+ text-align: right;
+}
+
+.consolidated-circulation-card .card-footer {
+ padding: 0.5rem 1rem 0.75rem 1rem;
+ border-top: 1px solid var(--border-color);
+ background-color: rgba(0, 0, 0, 0.02);
+}
+
+@media (prefers-color-scheme: dark) {
+ .consolidated-circulation-card .card-footer {
+ background-color: rgba(255, 255, 255, 0.03);
+ }
+}
+
+.consolidated-circulation-card .status-text {
+ font-size: 0.8rem;
+ color: var(--subtitle-color);
+ line-height: 1.4;
+ display: block;
+}
+
+/* Responsive adjustments */
+@media (max-width: 480px) {
+ .consolidated-circulation-card .card-header {
+ gap: 0.5rem;
+ padding: 0.75rem 0.875rem;
+ }
+
+ .consolidated-circulation-card .arrival-time {
+ font-size: 1rem;
+ }
+
+ .consolidated-circulation-card .card-footer {
+ padding: 0.5rem 0.875rem 0.625rem 0.875rem;
+ }
+
+ .consolidated-circulation-card .status-text {
+ font-size: 0.9rem;
+ }
+}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
new file mode 100644
index 0000000..a1b50f2
--- /dev/null
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -0,0 +1,217 @@
+import { useTranslation } from "react-i18next";
+import { Clock } from "lucide-react";
+import { type ConsolidatedCirculation } from "~routes/stops-$id";
+import LineIcon from "~components/LineIcon";
+import { type RegionConfig } from "~data/RegionConfig";
+
+import './ConsolidatedCirculationList.css';
+
+interface RegularTableProps {
+ data: ConsolidatedCirculation[];
+ dataDate: Date | null;
+ regionConfig: RegionConfig;
+}
+
+// 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;
+ case 201:
+ displayLine = "U1";
+ break;
+ case 202:
+ displayLine = "U2";
+ break;
+ default:
+ displayLine = `L${lineNumber}`;
+ }
+
+ return `${displayLine}-${turnNumber}`;
+};
+
+export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
+ data,
+ dataDate,
+ regionConfig,
+}) => {
+ const { t } = useTranslation();
+
+ const absoluteArrivalTime = (minutes: number) => {
+ const now = new Date();
+ const arrival = new Date(now.getTime() + minutes * 60000);
+ return Intl.DateTimeFormat(
+ typeof navigator !== "undefined" ? navigator.language : "en",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ },
+ ).format(arrival);
+ };
+
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
+ }
+ };
+
+ const getDelayText = (estimate: ConsolidatedCirculation): string | null => {
+ if (!estimate.schedule || !estimate.realTime) {
+ return null;
+ }
+
+ const delay = estimate.realTime.minutes - estimate.schedule.minutes;
+
+ if (delay >= -1 && delay <= 2) {
+ return t("estimates.on_time", "on time");
+ } else if (delay > 2) {
+ return t("estimates.minutes_late", "{{minutes}} minutes late", { minutes: delay });
+ } else {
+ return t("estimates.minutes_early", "{{minutes}} minutes early", { minutes: Math.abs(delay) });
+ }
+ };
+
+ const getTripIdDisplay = (tripId: string): string => {
+ const parts = tripId.split("_");
+ return parts.length > 1 ? parts[1] : tripId;
+ };
+
+ const getTimeClass = (estimate: ConsolidatedCirculation): string => {
+ if (estimate.realTime && estimate.schedule?.running) {
+ return "time-running";
+ }
+
+ if (estimate.realTime && !estimate.schedule) {
+ return "time-running";
+ }
+
+ else if (estimate.realTime && !estimate.schedule?.running) {
+ return "time-delayed";
+ }
+
+ return "time-scheduled";
+ };
+
+ const sortedData = [...data].sort(
+ (a, b) => (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
+ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
+ );
+
+ return (
+ <div className="consolidated-circulation-container">
+ <div className="consolidated-circulation-caption">
+ {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", {
+ time: dataDate?.toLocaleTimeString(),
+ })}
+ </div>
+
+ {sortedData.length === 0 ? (
+ <div className="consolidated-circulation-no-data">
+ {t("estimates.none", "No hay estimaciones disponibles")}
+ </div>
+ ) : (
+ <div className="consolidated-circulation-list">
+ {sortedData.map((estimate, idx) => {
+ const displayMinutes = estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0;
+ const timeClass = getTimeClass(estimate);
+ const delayText = getDelayText(estimate);
+
+ return (
+ <div key={idx} className="consolidated-circulation-card">
+ <div className="card-header">
+ <div className="line-info">
+ <LineIcon line={estimate.line} region={regionConfig.id} />
+ </div>
+
+ <div className="route-info">
+ <strong>{estimate.route}</strong>
+ </div>
+
+ <div className="time-info">
+ <div className={`arrival-time ${timeClass}`}>
+ <Clock />
+ {estimate.realTime
+ ? `${displayMinutes} ${t("estimates.minutes", "min")}`
+ : absoluteArrivalTime(displayMinutes)}
+ </div>
+ {estimate.realTime && estimate.realTime.distance >= 0 && (
+ <div className="distance-info">
+ {formatDistance(estimate.realTime.distance)}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="card-footer">
+ <span className="status-text">
+ {delayText && (
+ <>
+ {t("estimates.bus_is", "Bus is")} {delayText}.{" "}
+ </>
+ )}
+ </span>
+ <span className="status-text">
+ {estimate.schedule ? (
+ <>
+ {t("estimates.service", "Service")}{" "}
+ {parseServiceId(estimate.schedule.serviceId)}
+ {", "}
+ {t("estimates.trip", "trip")}{" "}
+ {getTripIdDisplay(estimate.schedule.tripId)}
+ </>
+ ) : (
+ <>
+ {t("estimates.unknown_service", "Unknown service. It may be a reinforcement or the service has a different name than planned.")}
+ </>
+ )}
+ </span>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+};
diff --git a/src/frontend/app/data/RegionConfig.ts b/src/frontend/app/data/RegionConfig.ts
index 08d915f..7722170 100644
--- a/src/frontend/app/data/RegionConfig.ts
+++ b/src/frontend/app/data/RegionConfig.ts
@@ -5,6 +5,7 @@ export interface RegionConfig {
name: string;
stopsEndpoint: string;
estimatesEndpoint: string;
+ consolidatedCirculationsEndpoint: string | null;
timetableEndpoint: string | null;
defaultCenter: [number, number]; // [lat, lng]
bounds?: {
@@ -22,6 +23,7 @@ export const REGIONS: Record<RegionId, RegionConfig> = {
name: "Vigo",
stopsEndpoint: "/stops/vigo.json",
estimatesEndpoint: "/api/vigo/GetStopEstimates",
+ consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
timetableEndpoint: "/api/vigo/GetStopTimetable",
defaultCenter: [42.229188855975046, -8.72246955783102],
bounds: {
@@ -37,6 +39,7 @@ export const REGIONS: Record<RegionId, RegionConfig> = {
name: "Santiago de Compostela",
stopsEndpoint: "/stops/santiago.json",
estimatesEndpoint: "/api/santiago/GetStopEstimates",
+ consolidatedCirculationsEndpoint: null, // Not available for Santiago
timetableEndpoint: null, // Not available for Santiago
defaultCenter: [42.8782, -8.5448],
bounds: {
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index d5dfed0..641c6c2 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -15,6 +15,7 @@
"table_style": "Table style:",
"table_style_regular": "Show in order",
"table_style_grouped": "Group by line",
+ "table_style_experimental_consolidated": "(EXPERIMENTAL) Consolidated data",
"map_position_mode": "Map position:",
"map_position_gps": "GPS position",
"map_position_last": "Where I left it",
@@ -37,7 +38,8 @@
"details_summary": "What does this mean?",
"details_table": "The timetable can be shown in two ways:",
"details_regular": "Stops are shown in the order they are visited. Apps like Infobus (Vitrasa) use this style.",
- "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style."
+ "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style.",
+ "details_experimental_consolidated": "Stops are shown using consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate. It works only in the Vigo region."
},
"stoplist": {
"search_placeholder": "Search stop by name or code...",
@@ -65,7 +67,18 @@
"distance": "Distance",
"not_available": "Not available",
"none": "No estimates available",
- "next_arrivals": "Next arrivals"
+ "next_arrivals": "Next arrivals",
+ "on_time": "on time",
+ "minutes_late": "{{minutes}} minutes late",
+ "minutes_early": "{{minutes}} minutes early",
+ "bus_is": "Bus is",
+ "service": "Service",
+ "trip": "trip",
+ "last_updated": "Updated at",
+ "reload": "Reload",
+ "unknown_service": "Unknown service. It may be a reinforcement or the service has a different name than planned.",
+ "experimental_feature": "Experimental feature",
+ "experimental_description": "This view uses consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate."
},
"timetable": {
"fullCaption": "Theoretical timetables for this stop",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 357da23..2fd3332 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -15,6 +15,7 @@
"table_style": "Estilo de tabla:",
"table_style_regular": "Mostrar por orden",
"table_style_grouped": "Agrupar por línea",
+ "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados",
"map_position_mode": "Posición del mapa:",
"map_position_gps": "Posición GPS",
"map_position_last": "Donde lo dejé",
@@ -37,7 +38,8 @@
"details_summary": "¿Qué significa esto?",
"details_table": "La tabla de horarios puede mostrarse de dos formas:",
"details_regular": "Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.",
- "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo."
+ "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.",
+ "details_experimental_consolidated": "Las paradas se muestran utilizando datos consolidados de múltiples fuentes en tiempo real. Esta función está en fase experimental y puede no ser completamente precisa. Funciona únicamente en la región de Vigo."
},
"stoplist": {
"search_placeholder": "Buscar parada por nombre o código...",
@@ -65,7 +67,18 @@
"distance": "Distancia",
"not_available": "No disponible",
"none": "No hay estimaciones disponibles",
- "next_arrivals": "Próximas llegadas"
+ "next_arrivals": "Próximas llegadas",
+ "on_time": "en hora",
+ "minutes_late": "{{minutes}} minutos tarde",
+ "minutes_early": "{{minutes}} minutos adelantado",
+ "bus_is": "Circula",
+ "service": "Servicio",
+ "trip": "viaje",
+ "last_updated": "Actualizado a las",
+ "reload": "Recargar",
+ "unknown_service": "Servicio desconocido. Puede tratarse de un refuerzo o de que el servicio lleva un nombre distinto al planificado.",
+ "experimental_feature": "Función experimental",
+ "experimental_description": "Esta vista utiliza datos consolidados de múltiples fuentes en tiempo real. Esta función está en fase experimental y puede no ser completamente precisa."
},
"timetable": {
"fullCaption": "Horarios teóricos de la parada",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 9dab87a..612e600 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -15,6 +15,7 @@
"table_style": "Estilo de táboa:",
"table_style_regular": "Mostrar por orde",
"table_style_grouped": "Agrupar por liña",
+ "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados",
"map_position_mode": "Posición do mapa:",
"map_position_gps": "Posición GPS",
"map_position_last": "Onde o deixei",
@@ -37,7 +38,8 @@
"details_summary": "Que significa isto?",
"details_table": "A táboa de horarios pode mostrarse de dúas formas:",
"details_regular": "As paradas móstranse na orde na que se visitan. Aplicacións como Infobus (Vitrasa) usan este estilo.",
- "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo."
+ "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo.",
+ "details_experimental_consolidated": "As paradas móstranse utilizando datos consolidados de múltiples fontes en tempo real. Esta función está en fase experimental e pode non ser completamente precisa. Funciona unicamente na rexión de Vigo."
},
"stoplist": {
"search_placeholder": "Buscar parada por nome ou código...",
@@ -65,7 +67,18 @@
"distance": "Distancia",
"not_available": "Non dispoñible",
"none": "Non hai estimacións dispoñibles",
- "next_arrivals": "Próximas chegadas"
+ "next_arrivals": "Próximas chegadas",
+ "on_time": "a tempo",
+ "minutes_late": "{{minutes}} minutos de atraso",
+ "minutes_early": "{{minutes}} minutos de adelanto",
+ "bus_is": "O autobús vai",
+ "service": "Servizo",
+ "trip": "viaxe",
+ "last_updated": "Actualizado ás",
+ "reload": "Recargar",
+ "unknown_service": "Servizo descoñecido. Pode tratarse dun reforzo ou de que o servizo leva un nome distinto ó planificado.",
+ "experimental_feature": "Función experimental",
+ "experimental_description": "Esta vista utiliza datos consolidados de múltiples fontes en tempo real. Esta función está en fase experimental e pode non ser completamente precisa."
},
"timetable": {
"fullCaption": "Horarios teóricos da parada",
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 7d81b5f..7192b33 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -3,6 +3,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("/map", "routes/map.tsx"),
+ route("/stops/:id", "routes/stops-$id.tsx"),
route("/estimates/:id", "routes/estimates-$id.tsx"),
route("/timetable/:id", "routes/timetable-$id.tsx"),
route("/settings", "routes/settings.tsx"),
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
index bb68c37..72ade06 100644
--- a/src/frontend/app/routes/estimates-$id.css
+++ b/src/frontend/app/routes/estimates-$id.css
@@ -237,3 +237,36 @@
.estimates-line-icon {
flex-shrink: 0;
}
+
+.experimental-notice {
+ background-color: #fff3cd;
+ border: 1px solid #ffc107;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ color: #856404;
+}
+
+.experimental-notice strong {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #856404;
+}
+
+.experimental-notice p {
+ margin: 0;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+@media (prefers-color-scheme: dark) {
+ .experimental-notice {
+ background-color: #3d3100;
+ border-color: #ffc107;
+ color: #ffd966;
+ }
+
+ .experimental-notice strong {
+ color: #ffd966;
+ }
+}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index 4502d9e..81c83ea 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -1,5 +1,5 @@
import { type JSX, useEffect, useState, useCallback } from "react";
-import { useParams, Link } from "react-router";
+import { useParams, Link, Navigate } from "react-router";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import { Star, Edit2, ExternalLink, RefreshCw } from "lucide-react";
import "./estimates-$id.css";
@@ -105,6 +105,11 @@ export default function Estimates() {
const { tableStyle, region } = useApp();
const regionConfig = getRegionConfig(region);
+ // Redirect to /stops/$id if table style is experimental_consolidated
+ if (tableStyle === "experimental_consolidated") {
+ return <Navigate to={`/stops/${params.id}`} replace />;
+ }
+
const parseError = (error: any): ErrorInfo => {
if (!navigator.onLine) {
return { type: "network", message: "No internet connection" };
@@ -171,10 +176,6 @@ export default function Estimates() {
}
}, [params.id, region, regionConfig.timetableEndpoint]);
- const refreshData = useCallback(async () => {
- await Promise.all([loadEstimatesData(), loadTimetableDataAsync()]);
- }, [loadEstimatesData, loadTimetableDataAsync]);
-
// Manual refresh function for pull-to-refresh and button
const handleManualRefresh = useCallback(async () => {
try {
@@ -299,7 +300,7 @@ export default function Estimates() {
{stopData && stopData.lines && stopData.lines.length > 0 && (
<div
- className={`estimates-lines-container ${stopData.lines.length >= 6 ? "scrollable" : ""}`}
+ className={`estimates-lines-container`}
>
{stopData.lines.map((line) => (
<div key={line} className="estimates-line-icon">
diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css
index d82cf8b..02708a7 100644
--- a/src/frontend/app/routes/settings.css
+++ b/src/frontend/app/routes/settings.css
@@ -37,7 +37,8 @@
.settings-content-inline {
display: flex;
- align-items: center;
+ flex-direction: column;
+ align-items: stretch;
margin-bottom: 1em;
}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index d687fab..c916877 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -83,22 +83,6 @@ export default function Settings() {
</select>
</div>
<div className="settings-content-inline">
- <label htmlFor="tableStyle" className="form-label-inline">
- {t("about.table_style")}
- </label>
- <select
- id="tableStyle"
- className="form-select-inline"
- value={tableStyle}
- onChange={(e) =>
- setTableStyle(e.target.value as "regular" | "grouped")
- }
- >
- <option value="regular">{t("about.table_style_regular")}</option>
- <option value="grouped">{t("about.table_style_grouped")}</option>
- </select>
- </div>
- <div className="settings-content-inline">
<label htmlFor="mapPositionMode" className="form-label-inline">
{t("about.map_position_mode")}
</label>
@@ -129,6 +113,24 @@ export default function Settings() {
<option value="en-GB">English</option>
</select>
</div>
+
+ <div className="settings-content-inline">
+ <label htmlFor="tableStyle" className="form-label-inline">
+ {t("about.table_style")}
+ </label>
+ <select
+ id="tableStyle"
+ className="form-select-inline"
+ value={tableStyle}
+ onChange={(e) =>
+ setTableStyle(e.target.value as "regular" | "grouped" | "experimental_consolidated")
+ }
+ >
+ <option value="regular">{t("about.table_style_regular")}</option>
+ <option value="grouped">{t("about.table_style_grouped")}</option>
+ <option value="experimental_consolidated">{t("about.table_style_experimental_consolidated")}</option>
+ </select>
+ </div>
<details className="form-details">
<summary>{t("about.details_summary")}</summary>
<p>{t("about.details_table")}</p>
@@ -137,6 +139,8 @@ export default function Settings() {
<dd>{t("about.details_regular")}</dd>
<dt>{t("about.table_style_grouped")}</dt>
<dd>{t("about.details_grouped")}</dd>
+ <dt>{t("about.table_style_experimental_consolidated")}</dt>
+ <dd>{t("about.details_experimental_consolidated")}</dd>
</dl>
</details>
</section>
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
new file mode 100644
index 0000000..ea60da7
--- /dev/null
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -0,0 +1,262 @@
+import { useEffect, useState, useCallback } from "react";
+import { useParams, Link } from "react-router";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import { Star, Edit2, ExternalLink, RefreshCw } from "lucide-react";
+import "./estimates-$id.css";
+import { useApp } from "../AppContext";
+import { useTranslation } from "react-i18next";
+import { PullToRefresh } from "~/components/PullToRefresh";
+import { useAutoRefresh } from "~/hooks/useAutoRefresh";
+import { type RegionId, getRegionConfig } from "~/data/RegionConfig";
+import { StopAlert } from "~/components/StopAlert";
+import LineIcon from "~/components/LineIcon";
+import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
+
+export interface ConsolidatedCirculation {
+ line: string;
+ route: string;
+ schedule?: {
+ running: boolean;
+ minutes: number;
+ serviceId: string;
+ tripId: string;
+ };
+ realTime?: {
+ minutes: number;
+ distance: number;
+ };
+}
+
+
+interface ErrorInfo {
+ type: "network" | "server" | "unknown";
+ status?: number;
+ message?: string;
+}
+
+const loadConsolidatedData = async (
+ region: RegionId,
+ stopId: string,
+): Promise<ConsolidatedCirculation[]> => {
+ const regionConfig = getRegionConfig(region);
+ const resp = await fetch(`${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ return await resp.json();
+};
+
+export default function Estimates() {
+ const { t } = useTranslation();
+ const params = useParams();
+ const stopIdNum = parseInt(params.id ?? "");
+ const [customName, setCustomName] = useState<string | undefined>(undefined);
+ const [stopData, setStopData] = useState<Stop | undefined>(undefined);
+
+ // Data state
+ const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
+ const [dataDate, setDataDate] = useState<Date | null>(null);
+ const [dataLoading, setDataLoading] = useState(true);
+ const [dataError, setDataError] = useState<ErrorInfo | null>(null);
+
+ const [favourited, setFavourited] = useState(false);
+ const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+ const { region } = useApp();
+ const regionConfig = getRegionConfig(region);
+
+ const parseError = (error: any): ErrorInfo => {
+ if (!navigator.onLine) {
+ return { type: "network", message: "No internet connection" };
+ }
+
+ if (
+ error.message?.includes("Failed to fetch") ||
+ error.message?.includes("NetworkError")
+ ) {
+ return { type: "network" };
+ }
+
+ if (error.message?.includes("HTTP")) {
+ const statusMatch = error.message.match(/HTTP (\d+):/);
+ const status = statusMatch ? parseInt(statusMatch[1]) : undefined;
+ return { type: "server", status };
+ }
+
+ return { type: "unknown", message: error.message };
+ };
+
+ const loadData = useCallback(async () => {
+ try {
+ setDataLoading(true);
+ setDataError(null);
+
+ const body = await loadConsolidatedData(region, params.id!);
+ setData(body);
+ setDataDate(new Date());
+
+ // Load stop data from StopDataProvider
+ const stop = await StopDataProvider.getStopById(region, stopIdNum);
+ setStopData(stop);
+ setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
+ } catch (error) {
+ console.error("Error loading consolidated data:", error);
+ setDataError(parseError(error));
+ setData(null);
+ setDataDate(null);
+ } finally {
+ setDataLoading(false);
+ }
+ }, [params.id, stopIdNum, region]);
+
+ const refreshData = useCallback(async () => {
+ await Promise.all([loadData()]);
+ }, [loadData]);
+
+ // Manual refresh function for pull-to-refresh and button
+ const handleManualRefresh = useCallback(async () => {
+ try {
+ setIsManualRefreshing(true);
+ // Only reload real-time estimates data, not timetable
+ await refreshData();
+ } finally {
+ setIsManualRefreshing(false);
+ }
+ }, [refreshData]);
+
+ // Auto-refresh estimates data every 30 seconds (only if not in error state)
+ useAutoRefresh({
+ onRefresh: refreshData,
+ interval: 30000,
+ enabled: !dataError,
+ });
+
+ useEffect(() => {
+ // Initial load
+ loadData();
+
+ StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
+ setFavourited(
+ StopDataProvider.isFavourite(region, parseInt(params.id ?? "")),
+ );
+ }, [params.id, region, loadData]);
+
+ const toggleFavourite = () => {
+ if (favourited) {
+ StopDataProvider.removeFavourite(region, stopIdNum);
+ setFavourited(false);
+ } else {
+ StopDataProvider.addFavourite(region, stopIdNum);
+ setFavourited(true);
+ }
+ };
+
+ // Helper function to get the display name for the stop
+ const getStopDisplayName = () => {
+ if (customName) return customName;
+ if (stopData?.name.intersect) return stopData.name.intersect;
+ if (stopData?.name.original) return stopData.name.original;
+ return `Parada ${stopIdNum}`;
+ };
+
+ const handleRename = () => {
+ const current = getStopDisplayName();
+ const input = window.prompt("Custom name for this stop:", current);
+ if (input === null) return; // cancelled
+ const trimmed = input.trim();
+ if (trimmed === "") {
+ StopDataProvider.removeCustomName(region, stopIdNum);
+ setCustomName(undefined);
+ } else {
+ StopDataProvider.setCustomName(region, stopIdNum, trimmed);
+ setCustomName(trimmed);
+ }
+ };
+
+ // Show loading skeleton while initial data is loading
+ if (dataLoading && !data) {
+ return (
+ <PullToRefresh
+ onRefresh={handleManualRefresh}
+ isRefreshing={isManualRefreshing}
+ >
+ <div className="page-container estimates-page">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star className="star-icon" />
+ <Edit2 className="edit-icon" />
+ {t("common.loading")}...
+ </h1>
+ </div>
+
+ <div className="table-responsive">
+ {/* TODO: Implement skeleton */}
+ </div>
+ </div>
+ </PullToRefresh>
+ );
+ }
+
+ return (
+ <PullToRefresh
+ onRefresh={handleManualRefresh}
+ isRefreshing={isManualRefreshing}
+ >
+ <div className="page-container estimates-page">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star
+ className={`star-icon ${favourited ? "active" : ""}`}
+ onClick={toggleFavourite}
+ />
+ <Edit2 className="edit-icon" onClick={handleRename} />
+ {getStopDisplayName()}{" "}
+ <span className="estimates-stop-id">({stopIdNum})</span>
+ </h1>
+
+ <button
+ className="manual-refresh-button"
+ onClick={handleManualRefresh}
+ disabled={isManualRefreshing || dataLoading}
+ title={t("estimates.reload", "Recargar estimaciones")}
+ >
+ <RefreshCw
+ className={`refresh-icon ${isManualRefreshing ? "spinning" : ""}`}
+ />
+ </button>
+ </div>
+
+ {stopData && stopData.lines && stopData.lines.length > 0 && (
+ <div
+ className={`estimates-lines-container`}
+ >
+ {stopData.lines.map((line) => (
+ <div key={line} className="estimates-line-icon">
+ <LineIcon line={line} region={region} rounded />
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="experimental-notice">
+ <strong>{t("estimates.experimental_feature", "Experimental feature")}</strong>
+ <p>{t("estimates.experimental_description", "This view uses consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate.")}</p>
+ </div>
+
+ {stopData && <StopAlert stop={stopData} />}
+
+ <div className="table-responsive">
+ {data ? (<>
+ <ConsolidatedCirculationList data={data} dataDate={dataDate} regionConfig={regionConfig} />
+ </>) : null}
+ </div>
+
+ </div>
+ </PullToRefresh>
+ );
+}
diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json
index 33b923b..f11f070 100644
--- a/src/frontend/tsconfig.json
+++ b/src/frontend/tsconfig.json
@@ -15,7 +15,11 @@
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
- "~/*": ["app/*"]
+ "~/*": ["app/*"],
+ "~data/*": ["app/data/*"],
+ "~components/*": ["app/components/*"],
+ "~routes/*": ["app/routes/*"],
+ "~i18n/*": ["app/i18n/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,