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/estimates-$id.css | 270 -------------- src/frontend/app/routes/estimates-$id.tsx | 374 -------------------- src/frontend/app/routes/home.tsx | 20 +- src/frontend/app/routes/map.tsx | 42 ++- src/frontend/app/routes/settings.tsx | 132 +------ src/frontend/app/routes/stops-$id.css | 2 +- src/frontend/app/routes/stops-$id.tsx | 48 +-- src/frontend/app/routes/timetable-$id.css | 224 ------------ src/frontend/app/routes/timetable-$id.tsx | 570 ------------------------------ 9 files changed, 67 insertions(+), 1615 deletions(-) delete mode 100644 src/frontend/app/routes/estimates-$id.css delete mode 100644 src/frontend/app/routes/estimates-$id.tsx delete mode 100644 src/frontend/app/routes/timetable-$id.css delete mode 100644 src/frontend/app/routes/timetable-$id.tsx (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css deleted file mode 100644 index 0658156..0000000 --- a/src/frontend/app/routes/estimates-$id.css +++ /dev/null @@ -1,270 +0,0 @@ -.table-responsive { - overflow-x: auto; - margin-bottom: 1.5rem; -} - -.table { - width: 100%; - border-collapse: collapse; -} - -.table caption { - margin-bottom: 0.5rem; - font-weight: 500; -} - -.table th, -.table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid #eee; -} - -.table th { - border-bottom: 2px solid #ddd; -} - -.table tfoot td { - text-align: center; -} - -/* Estimates page specific styles */ -.estimates-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - gap: 1rem; -} - -.manual-refresh-button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--primary-color); - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - min-width: max-content; -} - -.manual-refresh-button:hover:not(:disabled) { - background: var(--primary-color-hover); - transform: translateY(-1px); -} - -.manual-refresh-button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.refresh-icon { - width: 1.5rem; - height: 1.5rem; - transition: transform 0.2s ease; -} - -.refresh-icon.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (max-width: 640px) { - .estimates-header { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .manual-refresh-button { - align-self: flex-end; - padding: 0.4rem 0.6rem; - font-size: 0.8rem; - } -} - -.estimates-stop-id { - font-size: 1rem; - color: var(--subtitle-color); - margin-left: 0.5rem; -} - -.estimates-arrival { - color: #28a745; - font-weight: 500; -} - -.estimates-delayed { - color: #dc3545; -} - -.button-group { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.button { - padding: 0.75rem 1rem; - background-color: var(--button-background-color); - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - text-align: center; - text-decoration: none; - display: inline-block; -} - -.button:hover { - background-color: var(--button-hover-background-color); -} - -.button:disabled { - background-color: var(--button-disabled-background-color); - cursor: not-allowed; -} - -.star-icon { - margin-right: 0.5rem; - color: #ccc; - fill: none; -} - -.star-icon.active { - color: var(--star-color); - /* Yellow color for active star */ - fill: var(--star-color); -} - -/* Pencil (edit) icon next to header */ -.edit-icon { - margin-right: 0.5rem; - color: #ccc; - cursor: pointer; - stroke-width: 2px; -} - -.edit-icon:hover { - color: var(--star-color); -} - -/* Timetable section styles */ -.timetable-section { - padding-bottom: 3rem; -} - -/* 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; -} - -.estimates-lines-container { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.estimates-lines-container.scrollable { - flex-wrap: nowrap; - overflow-x: auto; - max-height: calc(2 * (var(--line-icon-height, 2rem) + 0.5rem)); - align-content: flex-start; - scrollbar-width: thin; -} - -.estimates-lines-container.scrollable::-webkit-scrollbar { - height: 6px; -} - -.estimates-lines-container.scrollable::-webkit-scrollbar-thumb { - background-color: var(--border-color); - border-radius: 3px; -} - -.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; -} - -[data-theme="dark"] .experimental-notice { - background-color: #3d3100; - border-color: #ffc107; - color: #ffd966; -} - -[data-theme="dark"] .experimental-notice strong { - color: #ffd966; -} diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx deleted file mode 100644 index afeb3d2..0000000 --- a/src/frontend/app/routes/estimates-$id.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { Edit2, ExternalLink, RefreshCw, Star } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate, useParams } from "react-router"; -import { ErrorDisplay } from "~/components/ErrorDisplay"; -import LineIcon from "~/components/LineIcon"; -import { PullToRefresh } from "~/components/PullToRefresh"; -import { - type ScheduledTable, - SchedulesTable, -} from "~/components/SchedulesTable"; -import { - EstimatesGroupedSkeleton, - SchedulesTableSkeleton, -} from "~/components/SchedulesTableSkeleton"; -import { StopAlert } from "~/components/StopAlert"; -import { TimetableSkeleton } from "~/components/TimetableSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; -import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import { useApp } from "../AppContext"; -import { GroupedTable } from "../components/GroupedTable"; -import { RegularTable } from "../components/RegularTable"; -import StopDataProvider, { type Stop } from "../data/StopDataProvider"; -import "./estimates-$id.css"; - -export interface Estimate { - line: string; - route: string; - minutes: number; - meters: number; -} - -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadData = async ( - region: RegionId, - stopId: string -): Promise => { - const regionConfig = getRegionConfig(region); - const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { - headers: { - Accept: "application/json", - }, - }); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - -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"); - } - - // 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(); -}; - -export default function Estimates() { - const { t } = useTranslation(); - const params = useParams(); - const stopIdNum = parseInt(params.id ?? ""); - const [customName, setCustomName] = useState(undefined); - const [stopData, setStopData] = useState(undefined); - - // Estimates data state - const [data, setData] = useState(null); - const [dataDate, setDataDate] = useState(null); - const [estimatesLoading, setEstimatesLoading] = useState(true); - const [estimatesError, setEstimatesError] = useState(null); - - // Timetable data state - const [timetableData, setTimetableData] = useState([]); - const [timetableLoading, setTimetableLoading] = useState(true); - const [timetableError, setTimetableError] = useState(null); - - const [favourited, setFavourited] = useState(false); - const [isManualRefreshing, setIsManualRefreshing] = useState(false); - const { tableStyle, region } = useApp(); - const regionConfig = getRegionConfig(region); - - // Redirect to /stops/$id if table style is experimental_consolidated - if (tableStyle === "experimental_consolidated") { - return ; - } - - 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 loadEstimatesData = useCallback(async () => { - try { - setEstimatesLoading(true); - setEstimatesError(null); - - const body = await loadData(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 estimates data:", error); - setEstimatesError(parseError(error)); - setData(null); - setDataDate(null); - } finally { - setEstimatesLoading(false); - } - }, [params.id, stopIdNum, region]); - - const loadTimetableDataAsync = useCallback(async () => { - // Skip loading timetable if not available for this region - if (!regionConfig.timetableEndpoint) { - setTimetableLoading(false); - return; - } - - try { - setTimetableLoading(true); - setTimetableError(null); - - const timetableBody = await loadTimetableData(region, params.id!); - setTimetableData(timetableBody); - } catch (error) { - console.error("Error loading timetable data:", error); - setTimetableError(parseError(error)); - setTimetableData([]); - } finally { - setTimetableLoading(false); - } - }, [params.id, region, regionConfig.timetableEndpoint]); - - // 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 loadEstimatesData(); - } finally { - setIsManualRefreshing(false); - } - }, [loadEstimatesData]); - - // Auto-refresh estimates data every 30 seconds (only if not in error state) - useAutoRefresh({ - onRefresh: loadEstimatesData, - interval: 30000, - enabled: !estimatesError, - }); - - useEffect(() => { - // Initial load - loadEstimatesData(); - loadTimetableDataAsync(); - - StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); - setFavourited( - StopDataProvider.isFavourite(region, parseInt(params.id ?? "")) - ); - }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]); - - 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 (estimatesLoading && !data) { - return ( - -
-
-

- - - {t("common.loading")}... -

-
- -
- {tableStyle === "grouped" ? ( - - ) : ( - - )} -
- -
- -
-
-
- ); - } - - return ( - -
-
-

- - - {getStopDisplayName()}{" "} - ({stopIdNum}) -

- - -
- - {stopData && stopData.lines && stopData.lines.length > 0 && ( -
- {stopData.lines.map((line) => ( -
- -
- ))} -
- )} - - {stopData && } - -
- {estimatesLoading ? ( - tableStyle === "grouped" ? ( - - ) : ( - - ) - ) : estimatesError ? ( - - ) : data ? ( - tableStyle === "grouped" ? ( - - ) : ( - - ) - ) : null} -
- -
- {timetableLoading ? ( - - ) : timetableError ? ( - - ) : timetableData.length > 0 ? ( - <> - -
- - - {t("timetable.viewAll", "Ver todos los horarios")} - -
- - ) : null} -
-
-
- ); -} diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 31a8e6a..7d8338f 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -124,9 +124,9 @@ export default function StopList() { const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRadians(lat1)) * - Math.cos(toRadians(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }; @@ -160,8 +160,8 @@ export default function StopList() { // Load favourite and recent IDs immediately from localStorage useEffect(() => { - setFavouriteIds(StopDataProvider.getFavouriteIds(region)); - setRecentIds(StopDataProvider.getRecent(region)); + setFavouriteIds(StopDataProvider.getFavouriteIds()); + setRecentIds(StopDataProvider.getRecent()); }, [region]); // Load stops from network @@ -169,10 +169,10 @@ export default function StopList() { try { setLoading(true); - const stops = await StopDataProvider.loadStopsFromNetwork(region); + const stops = await StopDataProvider.loadStopsFromNetwork(); // Add favourite flags to stops - const favouriteStopsIds = StopDataProvider.getFavouriteIds(region); + const favouriteStopsIds = StopDataProvider.getFavouriteIds(); const stopsWithFavourites = stops.map((stop) => ({ ...stop, favourite: favouriteStopsIds.includes(stop.stopId), @@ -186,7 +186,7 @@ export default function StopList() { ); setFavouriteStops(favStops); - const recIds = StopDataProvider.getRecent(region); + const recIds = StopDataProvider.getRecent(); const recStops = recIds .map((id) => stopsWithFavourites.find((stop) => stop.stopId === id)) .filter(Boolean) as Stop[]; @@ -304,8 +304,8 @@ export default function StopList() { )} {!loading && data ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map( - (stop) => - ) + (stop) => + ) : null} diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index df4808d..343cf91 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -15,7 +15,7 @@ import Map, { type StyleSpecification } from "react-map-gl/maplibre"; import { StopSheet } from "~/components/StopSheet"; -import { getRegionConfig } from "~/config/RegionConfig"; +import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp } from "../AppContext"; @@ -40,7 +40,7 @@ export default function StopMap() { >([]); const [selectedStop, setSelectedStop] = useState(null); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { mapState, updateMapState, theme, region } = useApp(); + const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); const [mapStyleKey, setMapStyleKey] = useState("light"); @@ -62,7 +62,7 @@ export default function StopMap() { }; useEffect(() => { - StopDataProvider.getStops(region).then((data) => { + StopDataProvider.getStops().then((data) => { const features: GeoJsonFeature< Point, { stopId: number; name: string; lines: string[]; cancelled?: boolean } @@ -81,7 +81,7 @@ export default function StopMap() { })); setStops(features); }); - }, [region]); + }, []); useEffect(() => { //const styleName = "carto"; @@ -155,7 +155,7 @@ export default function StopMap() { const stopId = parseInt(props.stopId, 10); // fetch full stop to get lines array - StopDataProvider.getStopById(region, stopId) + StopDataProvider.getStopById(stopId) .then((stop) => { if (!stop) { console.warn("Stop not found:", stopId); @@ -186,14 +186,10 @@ export default function StopMap() { zoom: mapState.zoom, }} attributionControl={{ compact: false }} - maxBounds={ - getRegionConfig(region).bounds - ? [getRegionConfig(region).bounds!.sw, getRegionConfig(region).bounds!.ne] - : undefined - } + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} > - + - {selectedStop && ( - setIsSheetOpen(false)} - stop={selectedStop} - /> - )} - + { + selectedStop && ( + setIsSheetOpen(false)} + stop={selectedStop} + /> + ) + } + ); } diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index 351ccf0..faad5a6 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,76 +1,41 @@ -import { useState } from "react"; +import { Computer, Moon, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Theme, useApp } from "../AppContext"; -import { getAvailableRegions } from "../config/RegionConfig"; import "./settings.css"; export default function Settings() { const { t, i18n } = useTranslation(); usePageTitle(t("navbar.settings", "Ajustes")); - const navigate = useNavigate(); const { theme, setTheme, - tableStyle, - setTableStyle, mapPositionMode, - setMapPositionMode, - region, - setRegion, + setMapPositionMode } = useApp(); - const regions = getAvailableRegions(); - const [showModal, setShowModal] = useState(false); - const [pendingRegion, setPendingRegion] = useState(null); - - const handleRegionChange = (newRegion: string) => { - if (newRegion !== region) { - setPendingRegion(newRegion); - setShowModal(true); - } - }; - - const confirmRegionChange = () => { - if (pendingRegion) { - setRegion(pendingRegion as any); - setShowModal(false); - setPendingRegion(null); - navigate("/"); - } - }; - - const cancelRegionChange = () => { - setShowModal(false); - setPendingRegion(null); - }; - return (

{t("about.settings")}

-
- - -
+
+ +
+ + + +
+
+
-
- - -
-
- {t("about.details_summary")} -

{t("about.details_table")}

-
-
{t("about.table_style_regular")}
-
{t("about.details_regular")}
-
{t("about.table_style_grouped")}
-
{t("about.details_grouped")}
-
{t("about.table_style_experimental_consolidated")}
-
{t("about.details_experimental_consolidated")}
-
-
- - {showModal && ( -
-
e.stopPropagation()}> -

{t("about.region_change_title", "Cambiar región")}

-

- {t( - "about.region_change_message", - "¿Estás seguro de que quieres cambiar la región? Serás redirigido a la lista de paradas." - )} -

-
- - -
-
-
- )}
); } diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css index 4d204a7..1144584 100644 --- a/src/frontend/app/routes/stops-$id.css +++ b/src/frontend/app/routes/stops-$id.css @@ -13,7 +13,7 @@ display: flex; flex-direction: column; gap: 0.75rem; - padding-block: 0 1rem; + margin-block: 0 1rem; } .table { diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index de552bd..cdc74eb 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; +import { PullToRefresh } from "~/components/PullToRefresh"; import { StopAlert } from "~/components/StopAlert"; import { StopMapModal } from "~/components/StopMapModal"; import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; -import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; +import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import { useApp } from "../AppContext"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import "./stops-$id.css"; @@ -53,12 +53,10 @@ interface ErrorInfo { } const loadConsolidatedData = async ( - region: RegionId, stopId: string ): Promise => { - const regionConfig = getRegionConfig(region); const resp = await fetch( - `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, { headers: { Accept: "application/json", @@ -92,8 +90,6 @@ export default function Estimates() { const [selectedCirculationId, setSelectedCirculationId] = useState< string | undefined >(undefined); - const { region } = useApp(); - const regionConfig = getRegionConfig(region); // Helper function to get the display name for the stop const getStopDisplayName = useCallback(() => { @@ -131,14 +127,14 @@ export default function Estimates() { setDataLoading(true); setDataError(null); - const body = await loadConsolidatedData(region, params.id!); + const body = await loadConsolidatedData(params.id!); setData(body); setDataDate(new Date()); // Load stop data from StopDataProvider - const stop = await StopDataProvider.getStopById(region, stopIdNum); + const stop = await StopDataProvider.getStopById(stopIdNum); setStopData(stop); - setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); } catch (error) { console.error("Error loading consolidated data:", error); setDataError(parseError(error)); @@ -147,24 +143,21 @@ export default function Estimates() { } finally { setDataLoading(false); } - }, [params.id, stopIdNum, region]); + }, [params.id, stopIdNum]); 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: 12000, @@ -175,18 +168,18 @@ export default function Estimates() { // Initial load loadData(); - StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); + StopDataProvider.pushRecent(parseInt(params.id ?? "")); setFavourited( - StopDataProvider.isFavourite(region, parseInt(params.id ?? "")) + StopDataProvider.isFavourite(parseInt(params.id ?? "")) ); - }, [params.id, region, loadData]); + }, [params.id, loadData]); const toggleFavourite = () => { if (favourited) { - StopDataProvider.removeFavourite(region, stopIdNum); + StopDataProvider.removeFavourite(stopIdNum); setFavourited(false); } else { - StopDataProvider.addFavourite(region, stopIdNum); + StopDataProvider.addFavourite(stopIdNum); setFavourited(true); } }; @@ -197,16 +190,16 @@ export default function Estimates() { if (input === null) return; // cancelled const trimmed = input.trim(); if (trimmed === "") { - StopDataProvider.removeCustomName(region, stopIdNum); + StopDataProvider.removeCustomName(stopIdNum); setCustomName(undefined); } else { - StopDataProvider.setCustomName(region, stopIdNum, trimmed); + StopDataProvider.setCustomName(stopIdNum, trimmed); setCustomName(trimmed); } }; return ( - <> +
@@ -238,7 +231,7 @@ export default function Estimates() {
{stopData.lines.map((line) => (
- +
))}
@@ -262,7 +255,6 @@ export default function Estimates() { { setSelectedCirculationId(getCirculationId(estimate)); setIsMapModalOpen(true); @@ -271,11 +263,9 @@ export default function Estimates() { ) : null}
- {/* Map Modal - only render if we have stop data */} {stopData && ( ({ id: getCirculationId(c), line: c.line, @@ -285,8 +275,8 @@ export default function Estimates() { previousTripShapeId: c.previousTripShapeId, schedule: c.schedule ? { - shapeId: c.schedule.shapeId, - } + shapeId: c.schedule.shapeId, + } : undefined, }))} isOpen={isMapModalOpen} @@ -295,6 +285,6 @@ export default function Estimates() { /> )}
- + ); } diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css deleted file mode 100644 index 3815982..0000000 --- a/src/frontend/app/routes/timetable-$id.css +++ /dev/null @@ -1,224 +0,0 @@ -.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; - position: relative; - padding-bottom: 80px; /* Space for FAB */ -} - -.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; - 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; - background: transparent; - cursor: pointer; - transition: all 0.2s ease; -} - -.past-toggle:hover { - background-color: var(--link-color, #007bff); - color: white; - text-decoration: none; -} - -.past-toggle:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.past-toggle:disabled:hover { - background: transparent; - color: var(--link-color, #007bff); -} - -.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; - } -} - -/* Floating Action Button */ -.fab-container { - position: fixed; - bottom: 80px; - right: 20px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 1000; -} - -.fab { - width: 56px; - height: 56px; - border-radius: 50%; - border: none; - background-color: var(--button-background-color, #007bff); - color: white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - transition: all 0.3s ease; - animation: fadeIn 0.3s ease; -} - -.fab:hover { - background-color: var(--button-hover-background-color, #0069d9); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); - transform: scale(1.05); -} - -.fab:active { - transform: scale(0.95); -} - -.fab-icon { - width: 24px; - height: 24px; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Adjust FAB position on mobile */ -@media (max-width: 768px) { - .fab-container { - bottom: 70px; - right: 16px; - } - - .fab { - width: 48px; - height: 48px; - } - - .fab-icon { - width: 20px; - height: 20px; - } -} 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