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/components/ErrorDisplay.css | 82 ++++++ src/frontend/app/components/ErrorDisplay.tsx | 84 +++++++ .../app/components/EstimatesTableSkeleton.tsx | 114 +++++++++ src/frontend/app/components/PullToRefresh.css | 64 +++++ src/frontend/app/components/PullToRefresh.tsx | 160 ++++++++++++ src/frontend/app/components/StopItemSkeleton.tsx | 42 ++++ src/frontend/app/components/StopSheet.css | 97 +++++++- src/frontend/app/components/StopSheet.tsx | 127 +++++++--- src/frontend/app/components/StopSheetSkeleton.tsx | 66 +++++ src/frontend/app/components/TimetableSkeleton.tsx | 67 +++++ src/frontend/app/data/StopDataProvider.ts | 17 ++ 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 +++++++--- src/frontend/package.json | 46 ++-- 17 files changed, 1359 insertions(+), 189 deletions(-) create mode 100644 src/frontend/app/components/ErrorDisplay.css create mode 100644 src/frontend/app/components/ErrorDisplay.tsx create mode 100644 src/frontend/app/components/EstimatesTableSkeleton.tsx create mode 100644 src/frontend/app/components/StopItemSkeleton.tsx create mode 100644 src/frontend/app/components/StopSheetSkeleton.tsx create mode 100644 src/frontend/app/components/TimetableSkeleton.tsx diff --git a/src/frontend/app/components/ErrorDisplay.css b/src/frontend/app/components/ErrorDisplay.css new file mode 100644 index 0000000..096182a --- /dev/null +++ b/src/frontend/app/components/ErrorDisplay.css @@ -0,0 +1,82 @@ +.error-display { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + min-height: 200px; +} + +.error-content { + text-align: center; + max-width: 400px; +} + +.error-icon { + width: 48px; + height: 48px; + color: #e74c3c; + margin: 0 auto 1rem; + display: block; +} + +.error-title { + font-size: 1.5rem; + font-weight: 600; + color: #2c3e50; + margin: 0 0 0.5rem; +} + +.error-message { + color: #7f8c8d; + margin: 0 0 1.5rem; + line-height: 1.5; +} + +.error-retry-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #3498db; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.error-retry-button:hover { + background: #2980b9; +} + +.error-retry-button:active { + transform: translateY(1px); +} + +.retry-icon { + width: 16px; + height: 16px; +} + +/* Compact version for smaller spaces */ +.error-display.compact { + min-height: 120px; + padding: 1rem; +} + +.error-display.compact .error-icon { + width: 32px; + height: 32px; + margin-bottom: 0.75rem; +} + +.error-display.compact .error-title { + font-size: 1.2rem; +} + +.error-display.compact .error-message { + font-size: 0.9rem; + margin-bottom: 1rem; +} diff --git a/src/frontend/app/components/ErrorDisplay.tsx b/src/frontend/app/components/ErrorDisplay.tsx new file mode 100644 index 0000000..3c91db6 --- /dev/null +++ b/src/frontend/app/components/ErrorDisplay.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { AlertTriangle, RefreshCw, Wifi, WifiOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import "./ErrorDisplay.css"; + +interface ErrorDisplayProps { + error: { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; + }; + onRetry?: () => void; + title?: string; + className?: string; +} + +export const ErrorDisplay: React.FC = ({ + error, + onRetry, + title, + className = "" +}) => { + const { t } = useTranslation(); + + const getErrorIcon = () => { + switch (error.type) { + case 'network': + return ; + case 'server': + return ; + default: + return ; + } + }; + + const getErrorMessage = () => { + switch (error.type) { + case 'network': + return t("errors.network", "No hay conexión a internet. Comprueba tu conexión y vuelve a intentarlo."); + case 'server': + if (error.status === 404) { + return t("errors.not_found", "No se encontraron datos para esta parada."); + } + if (error.status === 500) { + return t("errors.server_error", "Error del servidor. Inténtalo de nuevo más tarde."); + } + if (error.status && error.status >= 400) { + return t("errors.client_error", "Error en la solicitud. Verifica que la parada existe."); + } + return t("errors.server_generic", "Error del servidor ({{status}})", { status: error.status || 'desconocido' }); + default: + return error.message || t("errors.unknown", "Ha ocurrido un error inesperado."); + } + }; + + const getErrorTitle = () => { + if (title) return title; + + switch (error.type) { + case 'network': + return t("errors.network_title", "Sin conexión"); + case 'server': + return t("errors.server_title", "Error del servidor"); + default: + return t("errors.unknown_title", "Error"); + } + }; + + return ( +
+
+ {getErrorIcon()} +

{getErrorTitle()}

+

{getErrorMessage()}

+ {onRetry && ( + + )} +
+
+ ); +}; diff --git a/src/frontend/app/components/EstimatesTableSkeleton.tsx b/src/frontend/app/components/EstimatesTableSkeleton.tsx new file mode 100644 index 0000000..2ef770b --- /dev/null +++ b/src/frontend/app/components/EstimatesTableSkeleton.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { useTranslation } from "react-i18next"; + +interface EstimatesTableSkeletonProps { + rows?: number; +} + +export const EstimatesTableSkeleton: React.FC = ({ + rows = 3 +}) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + {Array.from({ length: rows }, (_, index) => ( + + + + + + + ))} + +
+ +
{t("estimates.line", "Línea")}{t("estimates.route", "Ruta")}{t("estimates.arrival", "Llegada")}{t("estimates.distance", "Distancia")}
+ + + + +
+ + +
+
+ +
+
+ ); +}; + +interface EstimatesGroupedSkeletonProps { + groups?: number; + rowsPerGroup?: number; +} + +export const EstimatesGroupedSkeleton: React.FC = ({ + groups = 3, + rowsPerGroup = 2 +}) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + {Array.from({ length: groups }, (_, groupIndex) => ( + + {Array.from({ length: rowsPerGroup }, (_, rowIndex) => ( + + + + + + + ))} + + ))} + +
+ +
{t("estimates.line", "Línea")}{t("estimates.route", "Ruta")}{t("estimates.arrival", "Llegada")}{t("estimates.distance", "Distancia")}
+ {rowIndex === 0 && ( + + )} + + + +
+ + +
+
+ +
+
+ ); +}; diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css index e69de29..d4946d2 100644 --- a/src/frontend/app/components/PullToRefresh.css +++ b/src/frontend/app/components/PullToRefresh.css @@ -0,0 +1,64 @@ +.pull-to-refresh-container { + position: relative; + width: 100%; + height: 100%; +} + +.pull-to-refresh-indicator { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + pointer-events: none; +} + +.refresh-icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--surface-color, #f8f9fa); + border: 2px solid var(--border-color, #e9ecef); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.refresh-icon-container.active { + background: var(--primary-color, #007bff); + border-color: var(--primary-color, #007bff); +} + +.refresh-icon { + width: 20px; + height: 20px; + color: var(--text-secondary, #6c757d); + transition: color 0.2s ease; +} + +.refresh-icon-container.active .refresh-icon { + color: white; +} + +.refresh-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.pull-to-refresh-content { + width: 100%; + height: 100%; + /* Completely normal scrolling */ + overflow: visible; + touch-action: auto; +} diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx index e69de29..f8ea590 100644 --- a/src/frontend/app/components/PullToRefresh.tsx +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -0,0 +1,160 @@ +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { motion, useMotionValue, useTransform } from "framer-motion"; +import { RefreshCw } from "lucide-react"; +import "./PullToRefresh.css"; + +interface PullToRefreshProps { + onRefresh: () => Promise | void; + isRefreshing?: boolean; + children: React.ReactNode; + threshold?: number; +} + +export const PullToRefresh: React.FC = ({ + onRefresh, + isRefreshing = false, + children, + threshold = 60, +}) => { + const [isActive, setIsActive] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const containerRef = useRef(null); + const startY = useRef(0); + + const y = useMotionValue(0); + const opacity = useTransform(y, [0, threshold], [0, 1]); + const scale = useTransform(y, [0, threshold], [0.5, 1]); + const rotate = useTransform(y, [0, threshold], [0, 180]); + + const isAtPageTop = useCallback(() => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; + return scrollTop <= 10; // Increased tolerance to 10px + }, []); + + const handleTouchStart = useCallback((e: TouchEvent) => { + // Very strict check - must be at absolute top + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 0 || isRefreshing) { + setIsPulling(false); + setIsActive(false); + return; + } + + startY.current = e.touches[0].clientY; + setIsPulling(true); + }, [isRefreshing]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!isPulling) return; + + // Continuously check if we're still at the top during the gesture + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 10) { + // Cancel pull-to-refresh if we've scrolled away from top + setIsPulling(false); + setIsActive(false); + y.set(0); + return; + } + + const currentY = e.touches[0].clientY; + const pullDistance = currentY - startY.current; + + if (pullDistance > 0) { + // Only prevent default when the event is cancelable + if (e.cancelable) { + e.preventDefault(); + } + + const dampedDistance = Math.min(pullDistance * 0.5, threshold * 1.2); + y.set(dampedDistance); + + if (dampedDistance >= threshold && !isActive) { + setIsActive(true); + // Only vibrate if user activation is available and vibrate is supported + if (navigator.vibrate && navigator.userActivation?.hasBeenActive) { + navigator.vibrate(50); + } + } else if (dampedDistance < threshold && isActive) { + setIsActive(false); + } + } else { + // Reset if pulling up + y.set(0); + setIsActive(false); + } + }, [isPulling, threshold, isActive, y]); + + const handleTouchEnd = useCallback(async () => { + if (!isPulling) return; + + setIsPulling(false); + + if (isActive && y.get() >= threshold && !isRefreshing) { + try { + await onRefresh(); + } catch (error) { + console.error('Refresh failed:', error); + } + } + + // Always reset state + setIsActive(false); + y.set(0); + startY.current = 0; + }, [isPulling, isActive, threshold, isRefreshing, onRefresh, y]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Use passive: false for touchmove to allow preventDefault + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd, { passive: true }); + + return () => { + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + + return ( +
+ {/* Simple indicator */} + {isPulling && ( + + + + + + )} + + {/* Normal content - no transform interference */} +
+ {children} +
+
+ ); +}; diff --git a/src/frontend/app/components/StopItemSkeleton.tsx b/src/frontend/app/components/StopItemSkeleton.tsx new file mode 100644 index 0000000..72f7506 --- /dev/null +++ b/src/frontend/app/components/StopItemSkeleton.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; + +interface StopItemSkeletonProps { + showId?: boolean; + stopId?: number; +} + +const StopItemSkeleton: React.FC = ({ + showId = false, + stopId +}) => { + return ( + +
  • +
    + + {showId && stopId && ( + <>({stopId}) + )} + + +
    + +
    +
    +
  • +
    + ); +}; + +export default StopItemSkeleton; diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css index 735e6cf..165f9fe 100644 --- a/src/frontend/app/components/StopSheet.css +++ b/src/frontend/app/components/StopSheet.css @@ -102,19 +102,83 @@ color: var(--subtitle-color); } +.stop-sheet-footer { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1rem 0; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.stop-sheet-timestamp { + font-size: 0.8rem; + color: var(--subtitle-color); + text-align: center; +} + +.stop-sheet-actions { + display: flex; + gap: 0.75rem; +} + +.stop-sheet-reload { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + justify-content: center; +} + +.stop-sheet-reload:hover:not(:disabled) { + background: var(--message-background-color); + border-color: var(--button-background-color); +} + +.stop-sheet-reload:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reload-icon { + width: 14px; + height: 14px; + transition: transform 0.5s ease; +} + +.reload-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .stop-sheet-view-all { display: block; - padding: 12px 16px; + padding: 0.5rem 0.75rem; background-color: var(--button-background-color); color: white; text-decoration: none; text-align: center; - border-radius: 8px; + border-radius: 6px; font-weight: 500; + font-size: 0.85rem; transition: background-color 0.2s ease; - - margin-block-start: 1rem; - margin-inline-start: auto; + flex: 2; } .stop-sheet-view-all:hover { @@ -122,6 +186,29 @@ text-decoration: none; } +/* Error display adjustments for sheet */ +.stop-sheet-content .error-display { + margin: 1rem 0; +} + +.stop-sheet-content .error-display.compact { + min-height: 100px; + padding: 1rem; +} + +.stop-sheet-content .error-display.compact .error-icon { + width: 28px; + height: 28px; +} + +.stop-sheet-content .error-display.compact .error-title { + font-size: 1.1rem; +} + +.stop-sheet-content .error-display.compact .error-message { + font-size: 0.85rem; +} + [data-rsbs-overlay] { background-color: rgba(0, 0, 0, 0.3); } diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 8075e9d..e8000d1 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useState } from "react"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; +import { RefreshCw } from "lucide-react"; import LineIcon from "./LineIcon"; +import { StopSheetSkeleton } from "./StopSheetSkeleton"; +import { ErrorDisplay } from "./ErrorDisplay"; import { type StopDetails } from "../routes/estimates-$id"; import "./StopSheet.css"; @@ -13,12 +16,26 @@ interface StopSheetProps { stopName: string; } +interface ErrorInfo { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; +} + const loadStopData = async (stopId: number): 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(); }; @@ -31,21 +48,47 @@ export const StopSheet: React.FC = ({ const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); - useEffect(() => { - if (isOpen && stopId) { + 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); setData(null); - loadStopData(stopId) - .then((stopData) => { - setData(stopData); - }) - .catch((error) => { - console.error("Failed to load stop data:", error); - }) - .finally(() => { - setLoading(false); - }); + + const stopData = await loadStopData(stopId); + setData(stopData); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load stop data:", err); + setError(parseError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen && stopId) { + loadData(); } }, [isOpen, stopId]); @@ -81,7 +124,7 @@ export const StopSheet: React.FC = ({ @@ -92,13 +135,16 @@ export const StopSheet: React.FC = ({ ({stopId}) - {loading && ( -
    - {t("common.loading", "Loading...")} -
    - )} - - {data && !loading && ( + {loading ? ( + + ) : error ? ( + + ) : data ? ( <>

    @@ -136,15 +182,40 @@ export const StopSheet: React.FC = ({ )}

    - - {t("map.view_all_estimates", "Ver todas las estimaciones")} - +
    + {lastUpdated && ( +
    + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + })} +
    + )} + +
    + + + + {t("map.view_all_estimates", "Ver todas las estimaciones")} + +
    +
    - )} + ) : null}
    diff --git a/src/frontend/app/components/StopSheetSkeleton.tsx b/src/frontend/app/components/StopSheetSkeleton.tsx new file mode 100644 index 0000000..91ea74f --- /dev/null +++ b/src/frontend/app/components/StopSheetSkeleton.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { useTranslation } from "react-i18next"; + +interface StopSheetSkeletonProps { + rows?: number; +} + +export const StopSheetSkeleton: React.FC = ({ + rows = 4 +}) => { + const { t } = useTranslation(); + + return ( + +
    +

    + {t("estimates.next_arrivals", "Next arrivals")} +

    + +
    + {Array.from({ length: rows }, (_, index) => ( +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    + ))} +
    +
    + +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + ); +}; diff --git a/src/frontend/app/components/TimetableSkeleton.tsx b/src/frontend/app/components/TimetableSkeleton.tsx new file mode 100644 index 0000000..01956ee --- /dev/null +++ b/src/frontend/app/components/TimetableSkeleton.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { useTranslation } from "react-i18next"; + +interface TimetableSkeletonProps { + rows?: number; +} + +export const TimetableSkeleton: React.FC = ({ + rows = 4 +}) => { + const { t } = useTranslation(); + + return ( + +
    +
    + +
    + +
    + {Array.from({ length: rows }, (_, index) => ( +
    +
    +
    + +
    + +
    + +
    + +
    + +
    +
    + +
    +
    + + +
    +
    +
    + ))} +
    +
    +
    + ); +}; diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index efb0414..3959400 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -148,6 +148,21 @@ function getRecent(): number[] { return []; } +function getFavouriteIds(): number[] { + const rawFavouriteStops = localStorage.getItem("favouriteStops"); + if (rawFavouriteStops) { + return JSON.parse(rawFavouriteStops) as number[]; + } + return []; +} + +// New function to load stops from network +async function loadStopsFromNetwork(): Promise { + const response = await fetch("/stops.json"); + const stops = (await response.json()) as Stop[]; + return stops.map((stop) => ({ ...stop, favourite: false } as Stop)); +} + export default { getStops, getStopById, @@ -160,4 +175,6 @@ export default { isFavourite, pushRecent, getRecent, + getFavouriteIds, + loadStopsFromNetwork, }; 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.")}

    diff --git a/src/frontend/package.json b/src/frontend/package.json index 6463dd9..674a1fa 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -13,39 +13,41 @@ }, "dependencies": { "@fontsource-variable/roboto": "^5.2.6", - "@react-router/node": "^7.6.2", - "@react-router/serve": "^7.6.2", + "@react-router/node": "^7.8.2", + "@react-router/serve": "^7.8.2", + "framer-motion": "^12.23.12", "fuse.js": "^7.1.0", "isbot": "^5", - "lucide-react": "^0.510.0", + "lucide-react": "^0.542.0", "maplibre-theme": "^1.0.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "react-leaflet": "^5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", - "react-modal-sheet": "^4.4.0", - "react-router": "^7.6.0" + "react-loading-skeleton": "^3.5.0", + "react-modal-sheet": "^5.1.0", + "react-router": "^7.8.2" }, "devDependencies": { - "@eslint/js": "^9.26.0", - "@react-router/dev": "^7.6.2", - "@react-router/node": "^7.6.2", - "@react-router/serve": "^7.6.2", - "@types/leaflet": "^1.9.17", - "@types/node": "^22.15.17", - "@types/react": "^19.1.3", - "@types/react-dom": "^19.1.4", - "eslint": "^9.26.0", + "@eslint/js": "^9.35.0", + "@react-router/dev": "^7.8.2", + "@react-router/node": "^7.8.2", + "@react-router/serve": "^7.8.2", + "@types/leaflet": "^1.9.20", + "@types/node": "^24.3.1", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "eslint": "^9.35.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.1.0", - "jiti": "^2.4.2", - "maplibre-gl": "^5.6.0", + "globals": "^16.3.0", + "jiti": "^2.5.1", + "maplibre-gl": "^5.7.1", "pmtiles": "^4.3.0", "react-map-gl": "^8.0.4", - "typescript": "^5.8.3", - "typescript-eslint": "^8.32.0", - "vite": "^6.3.5", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", + "vite": "^7.1.4", "vite-tsconfig-paths": "5.1.4" }, "optionalDependencies": { -- cgit v1.3