aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
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/routes
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/routes')
-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
5 files changed, 447 insertions, 133 deletions
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>