From a68ba30716062b265f85c4be078a736c7135d7bc Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 30 Nov 2025 20:49:48 +0100 Subject: Refactor StopMap and Settings components; replace region config usage with REGION_DATA, update StopDataProvider calls, and improve UI elements. Remove unused timetable files and add Tailwind CSS support. --- src/frontend/app/routes/timetable-$id.tsx | 570 ------------------------------ 1 file changed, 570 deletions(-) delete mode 100644 src/frontend/app/routes/timetable-$id.tsx (limited to 'src/frontend/app/routes/timetable-$id.tsx') diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx deleted file mode 100644 index c036cb3..0000000 --- a/src/frontend/app/routes/timetable-$id.tsx +++ /dev/null @@ -1,570 +0,0 @@ -import { - ArrowLeft, - ChevronDown, - ChevronUp, - Clock, - Eye, - EyeOff, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link, useParams } from "react-router"; -import { useApp } from "~/AppContext"; -import { ErrorDisplay } from "~/components/ErrorDisplay"; -import { type ScheduledTable } from "~/components/SchedulesTable"; -import { TimetableSkeleton } from "~/components/TimetableSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; -import LineIcon from "../components/LineIcon"; -import StopDataProvider from "../data/StopDataProvider"; -import "./timetable-$id.css"; - -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadTimetableData = async ( - region: RegionId, - stopId: string -): Promise => { - const regionConfig = getRegionConfig(region); - - // Check if timetable is available for this region - if (!regionConfig.timetableEndpoint) { - throw new Error("Timetable not available for this region"); - } - - // Add delay to see skeletons in action (remove in production) - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Use "today" to let server determine date based on Europe/Madrid timezone - const resp = await fetch( - `${regionConfig.timetableEndpoint}?date=today&stopId=${stopId}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - -// Utility function to compare times -const timeToMinutes = (time: string): number => { - const [hours, minutes] = time.split(":").map(Number); - return hours * 60 + minutes; -}; - -// Utility function to format GTFS time for display (handle hours >= 24) -const formatTimeForDisplay = (time: string): string => { - const [hours, minutes] = time.split(":").map(Number); - const normalizedHours = hours % 24; - return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; -}; - -// Filter past entries (keep only a few recent past ones) -const filterTimetableData = ( - data: ScheduledTable[], - currentTime: string, - showPast: boolean = false -): ScheduledTable[] => { - if (showPast) return data; - - const currentMinutes = timeToMinutes(currentTime); - const sortedData = [...data].sort( - (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time) - ); - - // Find the current position - const currentIndex = sortedData.findIndex( - (entry) => timeToMinutes(entry.calling_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}`; -}; - -// Scroll threshold for showing FAB buttons (in pixels) -const SCROLL_THRESHOLD = 100; - -export default function Timetable() { - const { t } = useTranslation(); - const { region } = useApp(); - const params = useParams(); - const stopIdNum = parseInt(params.id ?? ""); - const [timetableData, setTimetableData] = useState([]); - const [customName, setCustomName] = useState(undefined); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showPastEntries, setShowPastEntries] = useState(false); - const nextEntryRef = useRef(null); - const containerRef = useRef(null); - const regionConfig = getRegionConfig(region); - - const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS - const filteredData = filterTimetableData( - timetableData, - currentTime, - showPastEntries - ); - - 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 = async () => { - // Check if timetable is available for this region - if (!regionConfig.timetableEndpoint) { - setError({ - type: "server", - status: 501, - message: "Timetable not available for this region", - }); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - - const timetableBody = await loadTimetableData(region, params.id!); - setTimetableData(timetableBody); - - if (timetableBody.length > 0) { - // Scroll to next entry after a short delay to allow rendering - setTimeout(() => { - const currentMinutes = timeToMinutes(currentTime); - const sortedData = [...timetableBody].sort( - (a, b) => - timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time) - ); - - const nextIndex = sortedData.findIndex( - (entry) => timeToMinutes(entry.calling_time) >= currentMinutes - ); - - if (nextIndex !== -1 && nextEntryRef.current) { - nextEntryRef.current.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - }, 500); - } - } catch (err) { - console.error("Error loading timetable data:", err); - setError(parseError(err)); - setTimetableData([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); - }, [params.id, region]); - - // Scroll FABs moved to ScrollFabManager component - - if (loading) { - return ( -
-
-

- {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) -

- - - {t("timetable.backToEstimates", "Volver a estimaciones")} - -
- -
-
- -
- - -
-
- ); - } - - return ( -
-
-

- {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) -

- - - {t("timetable.backToEstimates", "Volver a estimaciones")} - -
- - {error ? ( -
- -
- ) : timetableData.length === 0 ? ( -
-

- {t( - "timetable.noDataAvailable", - "No hay datos de horarios disponibles para hoy" - )} -

-

- {t( - "timetable.errorDetail", - "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde." - )} -

-
- ) : ( -
-
- -
- - - - {/* Floating Action Button */} - -
- )} -
- ); -} - -// Custom component for the full timetable with scroll reference -const TimetableTableWithScroll: React.FC<{ - data: ScheduledTable[]; - showAll: boolean; - currentTime: string; - nextEntryRef: React.RefObject; -}> = ({ data, showAll, currentTime, nextEntryRef }) => { - const { t } = useTranslation(); - const { region } = useApp(); - const nowMinutes = timeToMinutes(currentTime); - - return ( -
-
- {t("timetable.fullCaption", "Horarios teóricos de la parada")} -
- -
- {data.map((entry, index) => { - const entryMinutes = timeToMinutes(entry.calling_time); - const isPast = entryMinutes < nowMinutes; - const isNext = - !isPast && - (index === 0 || - timeToMinutes(data[index - 1]?.calling_time || "00:00:00") < - nowMinutes); - - return ( -
-
-
- -
- -
- {entry.route && entry.route.trim() ? ( - {entry.route} - ) : ( - - {t("timetable.noDestination", "Línea")} {entry.line} - - )} -
- -
- - {formatTimeForDisplay(entry.calling_time)} - -
- {parseServiceId(entry.service_id)} -
-
-
-
- {!isPast && entry.next_streets.length > 0 && ( -
- {entry.next_streets.join(" — ")} -
- )} -
-
- ); - })} -
- - {data.length === 0 && ( -

- {t("timetable.noData", "No hay datos de horarios disponibles")} -

- )} -
- ); -}; - -// Component to manage scroll-based FAB visibility globally within timetable -const ScrollFabManager: React.FC<{ - containerRef: React.RefObject; - nextEntryRef: React.RefObject; - currentTime: string; - data: ScheduledTable[]; - disabled?: boolean; -}> = ({ containerRef, nextEntryRef, currentTime, data, disabled = false }) => { - const { t } = useTranslation(); - const [showScrollTop, setShowScrollTop] = useState(false); - const [showScrollBottom, setShowScrollBottom] = useState(false); - const [showGoToNow, setShowGoToNow] = useState(false); - - // Find the actual scrollable ancestor (.main-content) if our container isn't scrollable - const getScrollContainer = () => { - let el: HTMLElement | null = containerRef.current; - while (el) { - const style = getComputedStyle(el); - const hasScroll = el.scrollHeight > el.clientHeight + 8; - const overflowY = style.overflowY; - if (hasScroll && (overflowY === "auto" || overflowY === "scroll")) { - return el; - } - el = el.parentElement; - } - return null; - }; - - useEffect(() => { - if (disabled) return; - const scrollEl = getScrollContainer(); - const useWindowScroll = !scrollEl; - - const handleScroll = () => { - const scrollTop = useWindowScroll - ? window.scrollY || document.documentElement.scrollTop || 0 - : scrollEl!.scrollTop; - const scrollHeight = useWindowScroll - ? document.documentElement.scrollHeight - : scrollEl!.scrollHeight; - const clientHeight = useWindowScroll - ? window.innerHeight - : scrollEl!.clientHeight; - - const scrollBottom = scrollHeight - scrollTop - clientHeight; - const threshold = 80; // slightly smaller threshold for responsiveness - setShowScrollTop(scrollTop > threshold); - setShowScrollBottom(scrollBottom > threshold); - - if (nextEntryRef.current) { - const rect = nextEntryRef.current.getBoundingClientRect(); - const isNextVisible = - rect.top >= 0 && rect.bottom <= window.innerHeight; - setShowGoToNow(!isNextVisible); - } - }; - - const target: any = useWindowScroll ? window : scrollEl!; - target.addEventListener("scroll", handleScroll, { passive: true }); - window.addEventListener("resize", handleScroll); - handleScroll(); - return () => { - target.removeEventListener("scroll", handleScroll); - window.removeEventListener("resize", handleScroll); - }; - }, [containerRef, nextEntryRef, disabled, data, currentTime]); - - const scrollToTop = () => { - const scrollEl = getScrollContainer(); - if (!scrollEl) { - window.scrollTo({ top: 0, behavior: "smooth" }); - } else { - scrollEl.scrollTo({ top: 0, behavior: "smooth" }); - } - }; - const scrollToBottom = () => { - const scrollEl = getScrollContainer(); - if (!scrollEl) { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior: "smooth", - }); - } else { - scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: "smooth" }); - } - }; - const scrollToNow = () => { - nextEntryRef.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }; - - if (disabled) return null; - if (!(showGoToNow || showScrollTop || showScrollBottom)) return null; - - return ( -
- {showGoToNow && !showScrollTop && !showScrollBottom && ( - - )} - {showScrollTop && ( - - )} - {showScrollBottom && !showScrollTop && ( - - )} -
- ); -}; -- cgit v1.3