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 +++++++++ 10 files changed, 870 insertions(+), 33 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 (limited to 'src/frontend/app/components') 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) => ( +
    +
    +
    + +
    + +
    + +
    + +
    + +
    +
    + +
    +
    + + +
    +
    +
    + ))} +
    +
    +
    + ); +}; -- cgit v1.3