diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-09-07 19:22:28 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-09-07 19:22:28 +0200 |
| commit | 80bcf4a5f29ab926c2208d5efb4c19087c600323 (patch) | |
| tree | 1e5826b29d8a22e057616e16069232f95788f3ba /src/frontend/app | |
| parent | 8182a08f60e88595984ba80b472f29ccf53c19bd (diff) | |
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.
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/components/ErrorDisplay.css | 82 | ||||
| -rw-r--r-- | src/frontend/app/components/ErrorDisplay.tsx | 84 | ||||
| -rw-r--r-- | src/frontend/app/components/EstimatesTableSkeleton.tsx | 114 | ||||
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.css | 64 | ||||
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.tsx | 160 | ||||
| -rw-r--r-- | src/frontend/app/components/StopItemSkeleton.tsx | 42 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.css | 97 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.tsx | 127 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheetSkeleton.tsx | 66 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableSkeleton.tsx | 67 | ||||
| -rw-r--r-- | src/frontend/app/data/StopDataProvider.ts | 17 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 60 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 272 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 100 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.css | 24 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.tsx | 124 |
16 files changed, 1334 insertions, 166 deletions
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<ErrorDisplayProps> = ({ + error, + onRetry, + title, + className = "" +}) => { + const { t } = useTranslation(); + + const getErrorIcon = () => { + switch (error.type) { + case 'network': + return <WifiOff className="error-icon" />; + case 'server': + return <AlertTriangle className="error-icon" />; + default: + return <AlertTriangle className="error-icon" />; + } + }; + + 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 ( + <div className={`error-display ${className}`}> + <div className="error-content"> + {getErrorIcon()} + <h3 className="error-title">{getErrorTitle()}</h3> + <p className="error-message">{getErrorMessage()}</p> + {onRetry && ( + <button className="error-retry-button" onClick={onRetry}> + <RefreshCw className="retry-icon" /> + {t("errors.retry", "Reintentar")} + </button> + )} + </div> + </div> + ); +}; 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<EstimatesTableSkeletonProps> = ({ + rows = 3 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <table className="table"> + <caption> + <Skeleton width="250px" /> + </caption> + + <thead> + <tr> + <th>{t("estimates.line", "Línea")}</th> + <th>{t("estimates.route", "Ruta")}</th> + <th>{t("estimates.arrival", "Llegada")}</th> + <th>{t("estimates.distance", "Distancia")}</th> + </tr> + </thead> + + <tbody> + {Array.from({ length: rows }, (_, index) => ( + <tr key={`skeleton-${index}`}> + <td> + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + </td> + <td> + <Skeleton width="120px" /> + </td> + <td> + <div style={{ display: "flex", flexDirection: "column", gap: "2px" }}> + <Skeleton width="60px" /> + <Skeleton width="40px" /> + </div> + </td> + <td> + <Skeleton width="50px" /> + </td> + </tr> + ))} + </tbody> + </table> + </SkeletonTheme> + ); +}; + +interface EstimatesGroupedSkeletonProps { + groups?: number; + rowsPerGroup?: number; +} + +export const EstimatesGroupedSkeleton: React.FC<EstimatesGroupedSkeletonProps> = ({ + groups = 3, + rowsPerGroup = 2 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <table className="table grouped-table"> + <caption> + <Skeleton width="250px" /> + </caption> + + <thead> + <tr> + <th>{t("estimates.line", "Línea")}</th> + <th>{t("estimates.route", "Ruta")}</th> + <th>{t("estimates.arrival", "Llegada")}</th> + <th>{t("estimates.distance", "Distancia")}</th> + </tr> + </thead> + + <tbody> + {Array.from({ length: groups }, (_, groupIndex) => ( + <React.Fragment key={`group-${groupIndex}`}> + {Array.from({ length: rowsPerGroup }, (_, rowIndex) => ( + <tr key={`skeleton-${groupIndex}-${rowIndex}`} className={rowIndex === 0 ? "group-start" : ""}> + <td> + {rowIndex === 0 && ( + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + )} + </td> + <td> + <Skeleton width="120px" /> + </td> + <td> + <div style={{ display: "flex", flexDirection: "column", gap: "2px" }}> + <Skeleton width="60px" /> + <Skeleton width="40px" /> + </div> + </td> + <td> + <Skeleton width="50px" /> + </td> + </tr> + ))} + </React.Fragment> + ))} + </tbody> + </table> + </SkeletonTheme> + ); +}; 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> | void; + isRefreshing?: boolean; + children: React.ReactNode; + threshold?: number; +} + +export const PullToRefresh: React.FC<PullToRefreshProps> = ({ + onRefresh, + isRefreshing = false, + children, + threshold = 60, +}) => { + const [isActive, setIsActive] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + const startY = useRef<number>(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 ( + <div className="pull-to-refresh-container" ref={containerRef}> + {/* Simple indicator */} + {isPulling && ( + <motion.div + className="pull-to-refresh-indicator" + style={{ opacity }} + > + <motion.div + className={`refresh-icon-container ${isActive ? 'active' : ''}`} + style={{ scale, rotate: isRefreshing ? 0 : rotate }} + > + <RefreshCw + className={`refresh-icon ${isRefreshing ? 'spinning' : ''}`} + /> + </motion.div> + </motion.div> + )} + + {/* Normal content - no transform interference */} + <div className="pull-to-refresh-content"> + {children} + </div> + </div> + ); +}; 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<StopItemSkeletonProps> = ({ + showId = false, + stopId +}) => { + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <li className="list-item"> + <div className="list-item-link"> + <span> + {showId && stopId && ( + <>({stopId}) </> + )} + </span> + <Skeleton + width={showId ? "60%" : "80%"} + style={{ display: "inline-block" }} + /> + <div className="line-icons" style={{ marginTop: "4px" }}> + <Skeleton + count={3} + width="30px" + height="20px" + inline={true} + style={{ marginRight: "0.5rem" }} + /> + </div> + </div> + </li> + </SkeletonTheme> + ); +}; + +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<StopDetails> => { + // 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<StopSheetProps> = ({ const { t } = useTranslation(); const [data, setData] = useState<StopDetails | null>(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState<ErrorInfo | null>(null); + const [lastUpdated, setLastUpdated] = useState<Date | null>(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<StopSheetProps> = ({ <Sheet isOpen={isOpen} onClose={onClose} - detent="content-height" + detent={"content-height" as any} > <Sheet.Container> <Sheet.Header /> @@ -92,13 +135,16 @@ export const StopSheet: React.FC<StopSheetProps> = ({ <span className="stop-sheet-id">({stopId})</span> </div> - {loading && ( - <div className="stop-sheet-loading"> - {t("common.loading", "Loading...")} - </div> - )} - - {data && !loading && ( + {loading ? ( + <StopSheetSkeleton /> + ) : error ? ( + <ErrorDisplay + error={error} + onRetry={loadData} + title={t("errors.estimates_title", "Error al cargar estimaciones")} + className="compact" + /> + ) : data ? ( <> <div className="stop-sheet-estimates"> <h3 className="stop-sheet-subtitle"> @@ -136,15 +182,40 @@ export const StopSheet: React.FC<StopSheetProps> = ({ )} </div> - <Link - to={`/estimates/${stopId}`} - className="stop-sheet-view-all" - onClick={onClose} - > - {t("map.view_all_estimates", "Ver todas las estimaciones")} - </Link> + <div className="stop-sheet-footer"> + {lastUpdated && ( + <div className="stop-sheet-timestamp"> + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + })} + </div> + )} + + <div className="stop-sheet-actions"> + <button + className="stop-sheet-reload" + onClick={loadData} + disabled={loading} + title={t("estimates.reload", "Recargar estimaciones")} + > + <RefreshCw className={`reload-icon ${loading ? 'spinning' : ''}`} /> + {t("estimates.reload", "Recargar")} + </button> + + <Link + to={`/estimates/${stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </div> + </div> </> - )} + ) : null} </div> </Sheet.Content> </Sheet.Container> 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<StopSheetSkeletonProps> = ({ + rows = 4 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + <div className="stop-sheet-estimates-list"> + {Array.from({ length: rows }, (_, index) => ( + <div key={`skeleton-${index}`} className="stop-sheet-estimate-item"> + <div className="stop-sheet-estimate-line"> + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + </div> + + <div className="stop-sheet-estimate-details"> + <div className="stop-sheet-estimate-route"> + <Skeleton width="120px" height="0.95rem" /> + </div> + <div className="stop-sheet-estimate-time"> + <Skeleton width="80px" height="0.85rem" /> + </div> + </div> + </div> + ))} + </div> + </div> + + <div className="stop-sheet-footer"> + <div className="stop-sheet-timestamp"> + <Skeleton width="140px" height="0.8rem" /> + </div> + + <div className="stop-sheet-actions"> + <div className="stop-sheet-reload" style={{ + opacity: 0.6, + pointerEvents: "none" + }}> + <Skeleton width="70px" height="0.85rem" /> + </div> + + <div className="stop-sheet-view-all" style={{ + background: "#f0f0f0", + cursor: "not-allowed", + pointerEvents: "none" + }}> + <Skeleton width="180px" height="0.85rem" /> + </div> + </div> + </div> + </SkeletonTheme> + ); +}; 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<TimetableSkeletonProps> = ({ + rows = 4 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <div className="timetable-container"> + <div className="timetable-caption"> + <Skeleton width="250px" height="1.1rem" /> + </div> + + <div className="timetable-cards"> + {Array.from({ length: rows }, (_, index) => ( + <div key={`timetable-skeleton-${index}`} className="timetable-card"> + <div className="card-header"> + <div className="line-info"> + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + </div> + + <div className="destination-info"> + <Skeleton width="120px" height="0.95rem" /> + </div> + + <div className="time-info"> + <Skeleton + width="60px" + height="1.1rem" + style={{ fontFamily: "monospace" }} + /> + </div> + </div> + + <div className="card-body"> + <div className="route-streets"> + <Skeleton + width="50px" + height="0.8rem" + style={{ + display: "inline-block", + borderRadius: "3px", + marginRight: "0.5rem" + }} + /> + <Skeleton + width="200px" + height="0.85rem" + style={{ display: "inline-block" }} + /> + </div> + </div> + </div> + ))} + </div> + </div> + </SkeletonTheme> + ); +}; 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<Stop[]> { + 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<StopDetails> => { + // 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<TimetableEntry[]> => { + // 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<string | undefined>(undefined); + + // Estimates data state const [data, setData] = useState<StopDetails | null>(null); const [dataDate, setDataDate] = useState<Date | null>(null); - const [favourited, setFavourited] = useState(false); + const [estimatesLoading, setEstimatesLoading] = useState(true); + const [estimatesError, setEstimatesError] = useState<ErrorInfo | null>(null); + + // Timetable data state const [timetableData, setTimetableData] = useState<TimetableEntry[]>([]); + const [timetableLoading, setTimetableLoading] = useState(true); + const [timetableError, setTimetableError] = useState<ErrorInfo | null>(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 <h1 className="page-title">{t("common.loading")}</h1>; + // Show loading skeleton while initial data is loading + if (estimatesLoading && !data) { + return ( + <PullToRefresh + onRefresh={handleManualRefresh} + isRefreshing={isManualRefreshing} + > + <div className="page-container estimates-page"> + <div className="estimates-header"> + <h1 className="page-title"> + <Star className="star-icon" /> + <Edit2 className="edit-icon" /> + {t("common.loading")}... + </h1> + </div> + + <div className="table-responsive"> + {tableStyle === "grouped" ? ( + <EstimatesGroupedSkeleton /> + ) : ( + <EstimatesTableSkeleton /> + )} + </div> + + <div className="timetable-section"> + <TimetableSkeleton /> + </div> + </div> + </PullToRefresh> + ); } return ( - <div className="page-container estimates-page"> - <div className="estimates-header"> - <h1 className="page-title"> - <Star - className={`star-icon ${favourited ? "active" : ""}`} - onClick={toggleFavourite} - /> - <Edit2 className="edit-icon" onClick={handleRename} /> - {customName ?? data.stop.name}{" "} - <span className="estimates-stop-id">({data.stop.id})</span> - </h1> - </div> + <PullToRefresh + onRefresh={handleManualRefresh} + isRefreshing={isManualRefreshing} + > + <div className="page-container estimates-page"> + <div className="estimates-header"> + <h1 className="page-title"> + <Star + className={`star-icon ${favourited ? "active" : ""}`} + onClick={toggleFavourite} + /> + <Edit2 className="edit-icon" onClick={handleRename} /> + {customName ?? data?.stop.name ?? `Parada ${stopIdNum}`}{" "} + <span className="estimates-stop-id">({data?.stop.id ?? stopIdNum})</span> + </h1> - <div className="table-responsive"> - {tableStyle === "grouped" ? ( - <GroupedTable data={data} dataDate={dataDate} /> - ) : ( - <RegularTable data={data} dataDate={dataDate} /> - )} - </div> + <button + className="manual-refresh-button" + onClick={handleManualRefresh} + disabled={isManualRefreshing || estimatesLoading} + title={t("estimates.reload", "Recargar estimaciones")} + > + <RefreshCw className={`refresh-icon ${isManualRefreshing ? 'spinning' : ''}`} /> + </button> + </div> - <div className="timetable-section"> - <TimetableTable - data={timetableData} - currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS - /> + <div className="table-responsive"> + {estimatesLoading ? ( + tableStyle === "grouped" ? ( + <EstimatesGroupedSkeleton /> + ) : ( + <EstimatesTableSkeleton /> + ) + ) : estimatesError ? ( + <ErrorDisplay + error={estimatesError} + onRetry={loadEstimatesData} + title={t("errors.estimates_title", "Error al cargar estimaciones")} + /> + ) : data ? ( + tableStyle === "grouped" ? ( + <GroupedTable data={data} dataDate={dataDate} /> + ) : ( + <RegularTable data={data} dataDate={dataDate} /> + ) + ) : null} + </div> - {timetableData.length > 0 && ( - <div className="timetable-actions"> - <Link - to={`/timetable/${params.id}`} - className="view-all-link" - > - <ExternalLink className="external-icon" /> - {t("timetable.viewAll", "Ver todos los horarios")} - </Link> - </div> - )} + <div className="timetable-section"> + {timetableLoading ? ( + <TimetableSkeleton /> + ) : timetableError ? ( + <ErrorDisplay + error={timetableError} + onRetry={loadTimetableDataAsync} + title={t("errors.timetable_title", "Error al cargar horarios")} + className="compact" + /> + ) : timetableData.length > 0 ? ( + <> + <TimetableTable + data={timetableData} + currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS + /> + <div className="timetable-actions"> + <Link + to={`/timetable/${params.id}`} + className="view-all-link" + > + <ExternalLink className="external-icon" /> + {t("timetable.viewAll", "Ver todos los horarios")} + </Link> + </div> + </> + ) : null} + </div> </div> - </div> + </PullToRefresh> ); } 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<Stop[] | null>(null); + const [loading, setLoading] = useState(true); const [searchResults, setSearchResults] = useState<Stop[] | null>(null); + const [favouriteIds, setFavouriteIds] = useState<number[]>([]); + const [recentIds, setRecentIds] = useState<number[]>([]); + const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]); + const [recentStops, setRecentStops] = useState<Stop[]>([]); const searchTimeout = useRef<NodeJS.Timeout | null>(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 <h1 className="page-title">{t("common.loading")}</h1>; - } - return ( <div className="page-container stoplist-page"> <h1 className="page-title">UrbanoVigo Web</h1> @@ -108,7 +131,7 @@ export default function StopList() { <div className="list-container"> <h2 className="page-subtitle">{t("stoplist.favourites")}</h2> - {favouritedStops?.length === 0 && ( + {favouriteIds.length === 0 && ( <p className="message"> {t( "stoplist.no_favourites", @@ -118,18 +141,28 @@ export default function StopList() { )} <ul className="list"> - {favouritedStops - ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} + {loading && favouriteIds.length > 0 && + favouriteIds.map((id) => ( + <StopItemSkeleton key={id} showId={true} stopId={id} /> + )) + } + {!loading && favouriteStops + .sort((a, b) => a.stopId - b.stopId) + .map((stop) => <StopItem key={stop.stopId} stop={stop} />)} </ul> </div> - {recentStops && recentStops.length > 0 && ( + {(recentIds.length > 0 || (!loading && recentStops.length > 0)) && ( <div className="list-container"> <h2 className="page-subtitle">{t("stoplist.recents")}</h2> <ul className="list"> - {recentStops.map((stop: Stop) => ( + {loading && recentIds.length > 0 && + recentIds.map((id) => ( + <StopItemSkeleton key={id} showId={true} stopId={id} /> + )) + } + {!loading && recentStops.map((stop) => ( <StopItem key={stop.stopId} stop={stop} /> ))} </ul> @@ -140,9 +173,16 @@ export default function StopList() { <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2> <ul className="list"> - {data + {loading && ( + <> + {Array.from({ length: 8 }, (_, index) => ( + <StopItemSkeleton key={`skeleton-${index}`} /> + ))} + </> + )} + {!loading && data ?.sort((a, b) => a.stopId - b.stopId) - .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} + .map((stop) => <StopItem key={stop.stopId} stop={stop} />)} </ul> </div> </div> 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<TimetableEntry[]> => { + // 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<TimetableEntry[]>([]); const [customName, setCustomName] = useState<string | undefined>(undefined); const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); + const [error, setError] = useState<ErrorInfo | null>(null); const [showPastEntries, setShowPastEntries] = useState(false); const nextEntryRef = useRef<HTMLDivElement>(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 <h1 className="page-title">{t("common.loading")}</h1>; - } + return ( + <div className="page-container"> + <div className="timetable-full-header"> + <h1 className="page-title"> + {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) + </h1> + <Link to={`/estimates/${params.id}`} className="back-link"> + <ArrowLeft className="back-icon" /> + {t("timetable.backToEstimates", "Volver a estimaciones")} + </Link> + </div> + + <div className="timetable-full-content"> + <div className="timetable-controls"> + <button className="past-toggle" disabled> + <Eye className="toggle-icon" /> + {t("timetable.showPast", "Mostrar todos")} + </button> + </div> - // Get stop name from timetable data or use stop ID - const stopName = customName || - (timetableData.length > 0 ? `Parada ${params.id}` : `Parada ${params.id}`); + <TimetableSkeleton rows={8} /> + </div> + </div> + ); + } return ( <div className="page-container"> <div className="timetable-full-header"> - <h1 className="page-title"> {t("timetable.fullTitle", "Horarios teóricos")} ({params.id}) </h1> @@ -159,8 +211,16 @@ export default function Timetable() { </div> {error ? ( + <div className="timetable-full-content"> + <ErrorDisplay + error={error} + onRetry={loadData} + title={t("errors.timetable_title", "Error al cargar horarios")} + /> + </div> + ) : timetableData.length === 0 ? ( <div className="error-message"> - <p>{error}</p> + <p>{t("timetable.noDataAvailable", "No hay datos de horarios disponibles para hoy")}</p> <p className="error-detail"> {t("timetable.errorDetail", "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde.")} </p> |
