diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/GetStopEstimates.cs | 86 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Program.cs | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableTable.css | 187 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableTable.tsx | 167 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 18 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 18 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 18 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 46 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 49 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.css | 139 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.tsx | 275 |
12 files changed, 1002 insertions, 3 deletions
diff --git a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs b/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs index 7d4a5e8..50d8c98 100644 --- a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs +++ b/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Costasdev.VigoTransitApi; +using System.Text.Json; namespace Costasdev.Busurbano.Backend; @@ -8,10 +10,14 @@ namespace Costasdev.Busurbano.Backend; public class ApiController : ControllerBase { private readonly VigoTransitApiClient _api; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; - public ApiController(HttpClient http) + public ApiController(HttpClient http, IMemoryCache cache) { _api = new VigoTransitApiClient(http); + _cache = cache; + _httpClient = http; } [HttpGet("GetStopEstimates")] @@ -39,5 +45,83 @@ public class ApiController : ControllerBase return new BadRequestObjectResult("Stop not found"); } } + + [HttpGet("GetStopTimetable")] + public async Task<IActionResult> GetStopTimetable() + { + // Get date parameter (default to today if not provided) + var dateString = Request.Query.TryGetValue("date", out var requestedDate) + ? requestedDate.ToString() + : DateTime.Today.ToString("yyyy-MM-dd"); + + // Validate date format + if (!DateTime.TryParseExact(dateString, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var parsedDate)) + { + return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + } + + // Get stopId parameter + if (!Request.Query.TryGetValue("stopId", out var requestedStopIdString)) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'stopId'."); + } + + if (!int.TryParse(requestedStopIdString, out var requestedStopId)) + { + return BadRequest("The provided stop id is not a valid number."); + } + + // Create cache key + var cacheKey = $"timetable_{dateString}_{requestedStopId}"; + + // Try to get from cache first + if (_cache.TryGetValue(cacheKey, out var cachedData)) + { + return new OkObjectResult(cachedData); + } + + try + { + // Fetch data from external API + var url = $"https://costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{requestedStopId}.json"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return NotFound($"Timetable data not found for stop {requestedStopId} on {dateString}"); + } + return StatusCode((int)response.StatusCode, "Error fetching timetable data"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var timetableData = JsonSerializer.Deserialize<JsonElement>(jsonContent); + + // Cache the data for 12 hours + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), + SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry + Priority = CacheItemPriority.Normal + }; + + _cache.Set(cacheKey, timetableData, cacheOptions); + + return new OkObjectResult(timetableData); + } + catch (HttpRequestException ex) + { + return StatusCode(500, $"Error fetching timetable data: {ex.Message}"); + } + catch (JsonException ex) + { + return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); + } + catch (Exception ex) + { + return StatusCode(500, $"Unexpected error: {ex.Message}"); + } + } } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index a394282..68f84fb 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -2,6 +2,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddHttpClient(); +builder.Services.AddMemoryCache(); var app = builder.Build(); 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> + ); +}; |
