aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-09-07 19:22:28 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2025-09-07 19:22:28 +0200
commit80bcf4a5f29ab926c2208d5efb4c19087c600323 (patch)
tree1e5826b29d8a22e057616e16069232f95788f3ba /src/frontend/app
parent8182a08f60e88595984ba80b472f29ccf53c19bd (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.css82
-rw-r--r--src/frontend/app/components/ErrorDisplay.tsx84
-rw-r--r--src/frontend/app/components/EstimatesTableSkeleton.tsx114
-rw-r--r--src/frontend/app/components/PullToRefresh.css64
-rw-r--r--src/frontend/app/components/PullToRefresh.tsx160
-rw-r--r--src/frontend/app/components/StopItemSkeleton.tsx42
-rw-r--r--src/frontend/app/components/StopSheet.css97
-rw-r--r--src/frontend/app/components/StopSheet.tsx127
-rw-r--r--src/frontend/app/components/StopSheetSkeleton.tsx66
-rw-r--r--src/frontend/app/components/TimetableSkeleton.tsx67
-rw-r--r--src/frontend/app/data/StopDataProvider.ts17
-rw-r--r--src/frontend/app/routes/estimates-$id.css60
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx272
-rw-r--r--src/frontend/app/routes/stoplist.tsx100
-rw-r--r--src/frontend/app/routes/timetable-$id.css24
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx124
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>