diff options
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/AppContext.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 7 | ||||
| -rw-r--r-- | src/frontend/app/components/SchedulesTable.css | 46 | ||||
| -rw-r--r-- | src/frontend/app/components/SchedulesTable.tsx | 6 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.css | 158 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx | 217 | ||||
| -rw-r--r-- | src/frontend/app/data/RegionConfig.ts | 3 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 17 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 17 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 17 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 33 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 13 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.css | 3 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 36 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 262 |
17 files changed, 786 insertions, 54 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> + ); +} |
