From 80bcf4a5f29ab926c2208d5efb4c19087c600323 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 7 Sep 2025 19:22:28 +0200 Subject: feat: Enhance StopSheet component with error handling and loading states - Added skeleton loading state to StopSheet for better UX during data fetch. - Implemented error handling with descriptive messages for network and server errors. - Introduced manual refresh functionality to reload stop estimates. - Updated styles for loading and error states. - Created StopSheetSkeleton and TimetableSkeleton components for consistent loading indicators. feat: Improve StopList component with loading indicators and network data fetching - Integrated loading state for StopList while fetching stops from the network. - Added skeleton loading indicators for favourite and recent stops. - Refactored data fetching logic to include favourite and recent stops with full data. - Enhanced user experience with better loading and error handling. feat: Update Timetable component with loading and error handling - Added loading skeletons to Timetable for improved user experience. - Implemented error handling for timetable data fetching. - Refactored data loading logic to handle errors gracefully and provide retry options. chore: Update package dependencies - Upgraded react-router, lucide-react, and other dependencies to their latest versions. - Updated types for TypeScript compatibility. --- src/frontend/app/routes/estimates-$id.css | 60 ++++++- src/frontend/app/routes/estimates-$id.tsx | 274 +++++++++++++++++++++++------- src/frontend/app/routes/stoplist.tsx | 100 +++++++---- src/frontend/app/routes/timetable-$id.css | 24 ++- src/frontend/app/routes/timetable-$id.tsx | 124 ++++++++++---- 5 files changed, 448 insertions(+), 134 deletions(-) (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index 8906147..66e7fb6 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -32,7 +32,64 @@ .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 { @@ -106,8 +163,7 @@ /* Timetable section styles */ .timetable-section { - padding-top: 1.5rem; - padding-bottom: 3rem; /* Add bottom padding before footer */ + padding-bottom: 3rem; } /* Timetable cards should be single column */ diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index ab10c53..4b232cb 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -1,13 +1,17 @@ import { type JSX, useEffect, useState, useCallback } from "react"; import { useParams, Link } from "react-router"; import StopDataProvider from "../data/StopDataProvider"; -import { Star, Edit2, ExternalLink } from "lucide-react"; +import { Star, Edit2, ExternalLink, RefreshCw } 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"; +import { EstimatesTableSkeleton, EstimatesGroupedSkeleton } from "../components/EstimatesTableSkeleton"; +import { TimetableSkeleton } from "../components/TimetableSkeleton"; +import { ErrorDisplay } from "../components/ErrorDisplay"; +import { PullToRefresh } from "../components/PullToRefresh"; import { useAutoRefresh } from "../hooks/useAutoRefresh"; export interface StopDetails { @@ -25,31 +29,45 @@ export interface StopDetails { }[]; } -const loadData = async (stopId: string) => { +interface ErrorInfo { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; +} + +const loadData = async (stopId: string): Promise => { + // Add delay to see skeletons in action (remove in production) + await new Promise(resolve => setTimeout(resolve, 1000)); + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { headers: { Accept: "application/json", }, }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return await resp.json(); }; -const loadTimetableData = async (stopId: string) => { +const loadTimetableData = async (stopId: string): Promise => { + // Add delay to see skeletons in action (remove in production) + await new Promise(resolve => setTimeout(resolve, 1500)); + 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 []; + const resp = await fetch(`/api/GetStopTimetable?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() { @@ -57,22 +75,73 @@ export default function Estimates() { const params = useParams(); const stopIdNum = parseInt(params.id ?? ""); const [customName, setCustomName] = useState(undefined); + + // Estimates data state const [data, setData] = useState(null); const [dataDate, setDataDate] = useState(null); - const [favourited, setFavourited] = useState(false); + 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 } = useApp(); + 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 () => { - const body: StopDetails = await loadData(params.id!); - setData(body); - setDataDate(new Date()); - setCustomName(StopDataProvider.getCustomName(stopIdNum)); + try { + setEstimatesLoading(true); + setEstimatesError(null); + + const body = await loadData(params.id!); + setData(body); + setDataDate(new Date()); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); + } catch (error) { + console.error('Error loading estimates data:', error); + setEstimatesError(parseError(error)); + setData(null); + setDataDate(null); + } finally { + setEstimatesLoading(false); + } }, [params.id, stopIdNum]); const loadTimetableDataAsync = useCallback(async () => { - const timetableBody: TimetableEntry[] = await loadTimetableData(params.id!); - setTimetableData(timetableBody); + try { + setTimetableLoading(true); + setTimetableError(null); + + const timetableBody = await loadTimetableData(params.id!); + setTimetableData(timetableBody); + } catch (error) { + console.error('Error loading timetable data:', error); + setTimetableError(parseError(error)); + setTimetableData([]); + } finally { + setTimetableLoading(false); + } }, [params.id]); const refreshData = useCallback(async () => { @@ -82,11 +151,22 @@ export default function Estimates() { ]); }, [loadEstimatesData, loadTimetableDataAsync]); - // Auto-refresh estimates data every 30 seconds + // 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: true, + enabled: !estimatesError, }); useEffect(() => { @@ -122,50 +202,116 @@ export default function Estimates() { } }; - if (data === null) { - return

{t("common.loading")}

; + // Show loading skeleton while initial data is loading + if (estimatesLoading && !data) { + return ( + +
+
+

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

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

- - - {customName ?? data.stop.name}{" "} - ({data.stop.id}) -

-
+ +
+
+

+ + + {customName ?? data?.stop.name ?? `Parada ${stopIdNum}`}{" "} + ({data?.stop.id ?? stopIdNum}) +

-
- {tableStyle === "grouped" ? ( - - ) : ( - - )} -
+ +
-
- - - {timetableData.length > 0 && ( -
- - - {t("timetable.viewAll", "Ver todos los horarios")} - -
- )} +
+ {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/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx index 885a0da..8b0ebe2 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import StopItem from "../components/StopItem"; +import StopItemSkeleton from "../components/StopItemSkeleton"; import Fuse from "fuse.js"; import "./stoplist.css"; import { useTranslation } from "react-i18next"; @@ -8,21 +9,63 @@ import { useTranslation } from "react-i18next"; export default function StopList() { const { t } = useTranslation(); const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); const [searchResults, setSearchResults] = useState(null); + const [favouriteIds, setFavouriteIds] = useState([]); + const [recentIds, setRecentIds] = useState([]); + const [favouriteStops, setFavouriteStops] = useState([]); + const [recentStops, setRecentStops] = useState([]); const searchTimeout = useRef(null); const randomPlaceholder = useMemo( () => t("stoplist.search_placeholder"), [t], ); + const fuse = useMemo( () => new Fuse(data || [], { threshold: 0.3, keys: ["name.original"] }), [data], ); + // Load favourite and recent IDs immediately from localStorage + useEffect(() => { + setFavouriteIds(StopDataProvider.getFavouriteIds()); + setRecentIds(StopDataProvider.getRecent()); + }, []); + + // Load stops from network const loadStops = useCallback(async () => { - const stops = await StopDataProvider.getStops(); - setData(stops); + try { + setLoading(true); + + const stops = await StopDataProvider.loadStopsFromNetwork(); + + // Add favourite flags to stops + const favouriteStopsIds = StopDataProvider.getFavouriteIds(); + const stopsWithFavourites = stops.map(stop => ({ + ...stop, + favourite: favouriteStopsIds.includes(stop.stopId) + })); + + setData(stopsWithFavourites); + + // Update favourite and recent stops with full data + const favStops = stopsWithFavourites.filter(stop => + favouriteStopsIds.includes(stop.stopId) + ); + setFavouriteStops(favStops); + + const recIds = StopDataProvider.getRecent(); + const recStops = recIds + .map(id => stopsWithFavourites.find(stop => stop.stopId === id)) + .filter(Boolean) as Stop[]; + setRecentStops(recStops.reverse()); + + } catch (error) { + console.error("Failed to load stops:", error); + } finally { + setLoading(false); + } }, []); useEffect(() => { @@ -53,26 +96,6 @@ export default function StopList() { }, 300); }; - const favouritedStops = useMemo(() => { - return data?.filter((stop) => stop.favourite) ?? []; - }, [data]); - - const recentStops = useMemo(() => { - // no recent items if data not loaded - if (!data) return null; - const recentIds = StopDataProvider.getRecent(); - if (recentIds.length === 0) return null; - // map and filter out missing entries - const stopsList = recentIds - .map((id) => data.find((stop) => stop.stopId === id)) - .filter((s): s is Stop => Boolean(s)); - return stopsList.reverse(); - }, [data]); - - if (data === null) { - return

{t("common.loading")}

; - } - return (

UrbanoVigo Web

@@ -108,7 +131,7 @@ export default function StopList() {

{t("stoplist.favourites")}

- {favouritedStops?.length === 0 && ( + {favouriteIds.length === 0 && (

{t( "stoplist.no_favourites", @@ -118,18 +141,28 @@ export default function StopList() { )}

    - {favouritedStops - ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => )} + {loading && favouriteIds.length > 0 && + favouriteIds.map((id) => ( + + )) + } + {!loading && favouriteStops + .sort((a, b) => a.stopId - b.stopId) + .map((stop) => )}
- {recentStops && recentStops.length > 0 && ( + {(recentIds.length > 0 || (!loading && recentStops.length > 0)) && (

{t("stoplist.recents")}

    - {recentStops.map((stop: Stop) => ( + {loading && recentIds.length > 0 && + recentIds.map((id) => ( + + )) + } + {!loading && recentStops.map((stop) => ( ))}
@@ -140,9 +173,16 @@ export default function StopList() {

{t("stoplist.all_stops", "Paradas")}

    - {data + {loading && ( + <> + {Array.from({ length: 8 }, (_, index) => ( + + ))} + + )} + {!loading && data ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => )} + .map((stop) => )}
diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css index 5296615..c834633 100644 --- a/src/frontend/app/routes/timetable-$id.css +++ b/src/frontend/app/routes/timetable-$id.css @@ -71,19 +71,31 @@ display: inline-flex; align-items: center; gap: 0.5rem; + color: var(--link-color, #007bff); + text-decoration: none; + font-weight: 500; padding: 0.5rem 1rem; - background-color: var(--button-background, #f8f9fa); - color: var(--text-primary, #333); - border: 1px solid var(--button-border, #dee2e6); + border: 1px solid var(--link-color, #007bff); border-radius: 6px; - font-size: 0.9rem; - font-weight: 500; + background: transparent; cursor: pointer; transition: all 0.2s ease; } .past-toggle:hover { - background-color: var(--button-hover-background, #e9ecef); + 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 { diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx index 073dddb..cb55f53 100644 --- a/src/frontend/app/routes/timetable-$id.tsx +++ b/src/frontend/app/routes/timetable-$id.tsx @@ -3,26 +3,34 @@ 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 { TimetableSkeleton } from "../components/TimetableSkeleton"; +import { ErrorDisplay } from "../components/ErrorDisplay"; import LineIcon from "../components/LineIcon"; import { useTranslation } from "react-i18next"; import "./timetable-$id.css"; -const loadTimetableData = async (stopId: string) => { +interface ErrorInfo { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; +} + +const loadTimetableData = async (stopId: string): Promise => { + // Add delay to see skeletons in action (remove in production) + await new Promise(resolve => setTimeout(resolve, 1000)); + 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 []; + const resp = await fetch(`/api/GetStopTimetable?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 @@ -96,20 +104,40 @@ export default function Timetable() { const [timetableData, setTimetableData] = useState([]); const [customName, setCustomName] = useState(undefined); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [showPastEntries, setShowPastEntries] = useState(false); const nextEntryRef = useRef(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[]) => { + 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 () => { + try { + setLoading(true); + setError(null); + + const timetableBody = await loadTimetableData(params.id!); setTimetableData(timetableBody); - setLoading(false); - if (timetableBody.length === 0) { - setError(t("timetable.noDataAvailable", "No hay datos de horarios disponibles para hoy")); - } else { + + if (timetableBody.length > 0) { // Scroll to next entry after a short delay to allow rendering setTimeout(() => { const currentMinutes = timeToMinutes(currentTime); @@ -129,26 +157,50 @@ export default function Timetable() { } }, 500); } - }).catch((err) => { - setError(t("timetable.loadError", "Error al cargar los horarios")); + } catch (err) { + console.error('Error loading timetable data:', err); + setError(parseError(err)); + setTimetableData([]); + } finally { setLoading(false); - }); + } + }; + useEffect(() => { + loadData(); setCustomName(StopDataProvider.getCustomName(stopIdNum)); - }, [params.id, stopIdNum, t, currentTime]); + }, [params.id]); if (loading) { - return

{t("common.loading")}

; - } + return ( +
+
+

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

+ + + {t("timetable.backToEstimates", "Volver a estimaciones")} + +
+ +
+
+ +
- // Get stop name from timetable data or use stop ID - const stopName = customName || - (timetableData.length > 0 ? `Parada ${params.id}` : `Parada ${params.id}`); + +
+
+ ); + } return (
-

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

@@ -159,8 +211,16 @@ export default function Timetable() {
{error ? ( +
+ +
+ ) : timetableData.length === 0 ? (
-

{error}

+

{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.")}

-- cgit v1.3