aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-30 20:49:48 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-30 20:49:48 +0100
commita68ba30716062b265f85c4be078a736c7135d7bc (patch)
treedd079a2d3860349402ad5b614659fedcb90c2b99 /src/frontend/app/routes
parentcee521142a4e0673b155d97c3e4825b7fec1987f (diff)
Refactor StopMap and Settings components; replace region config usage with REGION_DATA, update StopDataProvider calls, and improve UI elements. Remove unused timetable files and add Tailwind CSS support.
Diffstat (limited to 'src/frontend/app/routes')
-rw-r--r--src/frontend/app/routes/estimates-$id.css270
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx374
-rw-r--r--src/frontend/app/routes/home.tsx20
-rw-r--r--src/frontend/app/routes/map.tsx42
-rw-r--r--src/frontend/app/routes/settings.tsx132
-rw-r--r--src/frontend/app/routes/stops-$id.css2
-rw-r--r--src/frontend/app/routes/stops-$id.tsx48
-rw-r--r--src/frontend/app/routes/timetable-$id.css224
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx570
9 files changed, 67 insertions, 1615 deletions
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
deleted file mode 100644
index 0658156..0000000
--- a/src/frontend/app/routes/estimates-$id.css
+++ /dev/null
@@ -1,270 +0,0 @@
-.table-responsive {
- overflow-x: auto;
- margin-bottom: 1.5rem;
-}
-
-.table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.table caption {
- margin-bottom: 0.5rem;
- font-weight: 500;
-}
-
-.table th,
-.table td {
- padding: 0.75rem;
- text-align: left;
- border-bottom: 1px solid #eee;
-}
-
-.table th {
- border-bottom: 2px solid #ddd;
-}
-
-.table tfoot td {
- text-align: center;
-}
-
-/* Estimates page specific styles */
-.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 {
- font-size: 1rem;
- color: var(--subtitle-color);
- margin-left: 0.5rem;
-}
-
-.estimates-arrival {
- color: #28a745;
- font-weight: 500;
-}
-
-.estimates-delayed {
- color: #dc3545;
-}
-
-.button-group {
- display: flex;
- gap: 1rem;
- margin-bottom: 1.5rem;
- flex-wrap: wrap;
-}
-
-.button {
- padding: 0.75rem 1rem;
- background-color: var(--button-background-color);
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 1rem;
- font-weight: 500;
- cursor: pointer;
- text-align: center;
- text-decoration: none;
- display: inline-block;
-}
-
-.button:hover {
- background-color: var(--button-hover-background-color);
-}
-
-.button:disabled {
- background-color: var(--button-disabled-background-color);
- cursor: not-allowed;
-}
-
-.star-icon {
- margin-right: 0.5rem;
- color: #ccc;
- fill: none;
-}
-
-.star-icon.active {
- color: var(--star-color);
- /* Yellow color for active star */
- fill: var(--star-color);
-}
-
-/* Pencil (edit) icon next to header */
-.edit-icon {
- margin-right: 0.5rem;
- color: #ccc;
- cursor: pointer;
- stroke-width: 2px;
-}
-
-.edit-icon:hover {
- color: var(--star-color);
-}
-
-/* Timetable section styles */
-.timetable-section {
- padding-bottom: 3rem;
-}
-
-/* Timetable cards should be single column */
-.timetable-section .timetable-cards {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.timetable-section .timetable-card {
- padding: 0.875rem;
-}
-
-.timetable-actions {
- margin-top: 1.5rem;
- text-align: center;
-}
-
-.view-all-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- font-weight: 500;
- padding: 0.5rem 1rem;
- border: 1px solid var(--link-color, #007bff);
- border-radius: 6px;
- transition: all 0.2s ease;
-}
-
-.view-all-link:hover {
- background-color: var(--link-color, #007bff);
- color: white;
- text-decoration: none;
-}
-
-.external-icon {
- width: 1rem;
- height: 1rem;
-}
-
-.estimates-lines-container {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
- margin-bottom: 1rem;
-}
-
-.estimates-lines-container.scrollable {
- flex-wrap: nowrap;
- overflow-x: auto;
- max-height: calc(2 * (var(--line-icon-height, 2rem) + 0.5rem));
- align-content: flex-start;
- scrollbar-width: thin;
-}
-
-.estimates-lines-container.scrollable::-webkit-scrollbar {
- height: 6px;
-}
-
-.estimates-lines-container.scrollable::-webkit-scrollbar-thumb {
- background-color: var(--border-color);
- border-radius: 3px;
-}
-
-.estimates-line-icon {
- flex-shrink: 0;
-}
-
-.experimental-notice {
- background-color: #fff3cd;
- border: 1px solid #ffc107;
- border-radius: 8px;
- padding: 1rem;
- margin-bottom: 1rem;
- color: #856404;
-}
-
-.experimental-notice strong {
- display: block;
- margin-bottom: 0.5rem;
- color: #856404;
-}
-
-.experimental-notice p {
- margin: 0;
- font-size: 0.9rem;
- line-height: 1.4;
-}
-
-[data-theme="dark"] .experimental-notice {
- background-color: #3d3100;
- border-color: #ffc107;
- color: #ffd966;
-}
-
-[data-theme="dark"] .experimental-notice strong {
- color: #ffd966;
-}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
deleted file mode 100644
index afeb3d2..0000000
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ /dev/null
@@ -1,374 +0,0 @@
-import { Edit2, ExternalLink, RefreshCw, Star } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Link, Navigate, useParams } from "react-router";
-import { ErrorDisplay } from "~/components/ErrorDisplay";
-import LineIcon from "~/components/LineIcon";
-import { PullToRefresh } from "~/components/PullToRefresh";
-import {
- type ScheduledTable,
- SchedulesTable,
-} from "~/components/SchedulesTable";
-import {
- EstimatesGroupedSkeleton,
- SchedulesTableSkeleton,
-} from "~/components/SchedulesTableSkeleton";
-import { StopAlert } from "~/components/StopAlert";
-import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
-import { useAutoRefresh } from "~/hooks/useAutoRefresh";
-import { useApp } from "../AppContext";
-import { GroupedTable } from "../components/GroupedTable";
-import { RegularTable } from "../components/RegularTable";
-import StopDataProvider, { type Stop } from "../data/StopDataProvider";
-import "./estimates-$id.css";
-
-export interface Estimate {
- line: string;
- route: string;
- minutes: number;
- meters: number;
-}
-
-interface ErrorInfo {
- type: "network" | "server" | "unknown";
- status?: number;
- message?: string;
-}
-
-const loadData = async (
- region: RegionId,
- stopId: string
-): Promise<Estimate[]> => {
- const regionConfig = getRegionConfig(region);
- const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, {
- headers: {
- Accept: "application/json",
- },
- });
-
- if (!resp.ok) {
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
- }
-
- return await resp.json();
-};
-
-const loadTimetableData = async (
- region: RegionId,
- stopId: string
-): Promise<ScheduledTable[]> => {
- const regionConfig = getRegionConfig(region);
-
- // Check if timetable is available for this region
- if (!regionConfig.timetableEndpoint) {
- throw new Error("Timetable not available for this region");
- }
-
- // Use "today" to let server determine date based on Europe/Madrid timezone
- const resp = await fetch(
- `${regionConfig.timetableEndpoint}?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() {
- const { t } = useTranslation();
- const params = useParams();
- const stopIdNum = parseInt(params.id ?? "");
- const [customName, setCustomName] = useState<string | undefined>(undefined);
- const [stopData, setStopData] = useState<Stop | undefined>(undefined);
-
- // Estimates data state
- const [data, setData] = useState<Estimate[] | null>(null);
- const [dataDate, setDataDate] = useState<Date | null>(null);
- const [estimatesLoading, setEstimatesLoading] = useState(true);
- const [estimatesError, setEstimatesError] = useState<ErrorInfo | null>(null);
-
- // Timetable data state
- const [timetableData, setTimetableData] = useState<ScheduledTable[]>([]);
- const [timetableLoading, setTimetableLoading] = useState(true);
- const [timetableError, setTimetableError] = useState<ErrorInfo | null>(null);
-
- const [favourited, setFavourited] = useState(false);
- const [isManualRefreshing, setIsManualRefreshing] = useState(false);
- const { tableStyle, region } = useApp();
- const regionConfig = getRegionConfig(region);
-
- // Redirect to /stops/$id if table style is experimental_consolidated
- if (tableStyle === "experimental_consolidated") {
- return <Navigate to={`/stops/${params.id}`} replace />;
- }
-
- 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 () => {
- try {
- setEstimatesLoading(true);
- setEstimatesError(null);
-
- const body = await loadData(region, params.id!);
- setData(body);
- setDataDate(new Date());
-
- // Load stop data from StopDataProvider
- const stop = await StopDataProvider.getStopById(region, stopIdNum);
- setStopData(stop);
- setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
- } catch (error) {
- console.error("Error loading estimates data:", error);
- setEstimatesError(parseError(error));
- setData(null);
- setDataDate(null);
- } finally {
- setEstimatesLoading(false);
- }
- }, [params.id, stopIdNum, region]);
-
- const loadTimetableDataAsync = useCallback(async () => {
- // Skip loading timetable if not available for this region
- if (!regionConfig.timetableEndpoint) {
- setTimetableLoading(false);
- return;
- }
-
- try {
- setTimetableLoading(true);
- setTimetableError(null);
-
- const timetableBody = await loadTimetableData(region, params.id!);
- setTimetableData(timetableBody);
- } catch (error) {
- console.error("Error loading timetable data:", error);
- setTimetableError(parseError(error));
- setTimetableData([]);
- } finally {
- setTimetableLoading(false);
- }
- }, [params.id, region, regionConfig.timetableEndpoint]);
-
- // 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: !estimatesError,
- });
-
- useEffect(() => {
- // Initial load
- loadEstimatesData();
- loadTimetableDataAsync();
-
- StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
- setFavourited(
- StopDataProvider.isFavourite(region, parseInt(params.id ?? ""))
- );
- }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]);
-
- const toggleFavourite = () => {
- if (favourited) {
- StopDataProvider.removeFavourite(region, stopIdNum);
- setFavourited(false);
- } else {
- StopDataProvider.addFavourite(region, stopIdNum);
- setFavourited(true);
- }
- };
-
- // Helper function to get the display name for the stop
- const getStopDisplayName = () => {
- if (customName) return customName;
- if (stopData?.name.intersect) return stopData.name.intersect;
- if (stopData?.name.original) return stopData.name.original;
- return `Parada ${stopIdNum}`;
- };
-
- const handleRename = () => {
- const current = getStopDisplayName();
- const input = window.prompt("Custom name for this stop:", current);
- if (input === null) return; // cancelled
- const trimmed = input.trim();
- if (trimmed === "") {
- StopDataProvider.removeCustomName(region, stopIdNum);
- setCustomName(undefined);
- } else {
- StopDataProvider.setCustomName(region, stopIdNum, trimmed);
- setCustomName(trimmed);
- }
- };
-
- // 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 />
- ) : (
- <SchedulesTableSkeleton />
- )}
- </div>
-
- <div className="timetable-section">
- <TimetableSkeleton />
- </div>
- </div>
- </PullToRefresh>
- );
- }
-
- return (
- <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} />
- {getStopDisplayName()}{" "}
- <span className="estimates-stop-id">({stopIdNum})</span>
- </h1>
-
- <button
- className="manual-refresh-button"
- onClick={handleManualRefresh}
- disabled={isManualRefreshing || estimatesLoading}
- title={t("estimates.reload", "Recargar estimaciones")}
- >
- <RefreshCw
- className={`refresh-icon ${isManualRefreshing ? "spinning" : ""}`}
- />
- </button>
- </div>
-
- {stopData && stopData.lines && stopData.lines.length > 0 && (
- <div className={`estimates-lines-container`}>
- {stopData.lines.map((line) => (
- <div key={line} className="estimates-line-icon">
- <LineIcon line={line} region={region} mode="rounded" />
- </div>
- ))}
- </div>
- )}
-
- {stopData && <StopAlert stop={stopData} />}
-
- <div className="table-responsive">
- {estimatesLoading ? (
- tableStyle === "grouped" ? (
- <EstimatesGroupedSkeleton />
- ) : (
- <SchedulesTableSkeleton />
- )
- ) : estimatesError ? (
- <ErrorDisplay
- error={estimatesError}
- onRetry={loadEstimatesData}
- title={t(
- "errors.estimates_title",
- "Error al cargar estimaciones"
- )}
- />
- ) : data ? (
- tableStyle === "grouped" ? (
- <GroupedTable
- data={data}
- dataDate={dataDate}
- regionConfig={regionConfig}
- />
- ) : (
- <RegularTable
- data={data}
- dataDate={dataDate}
- regionConfig={regionConfig}
- />
- )
- ) : null}
- </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 ? (
- <>
- <SchedulesTable
- 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>
- </PullToRefresh>
- );
-}
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 31a8e6a..7d8338f 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -124,9 +124,9 @@ export default function StopList() {
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) *
- Math.cos(toRadians(lat2)) *
- Math.sin(dLon / 2) *
- Math.sin(dLon / 2);
+ Math.cos(toRadians(lat2)) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
@@ -160,8 +160,8 @@ export default function StopList() {
// Load favourite and recent IDs immediately from localStorage
useEffect(() => {
- setFavouriteIds(StopDataProvider.getFavouriteIds(region));
- setRecentIds(StopDataProvider.getRecent(region));
+ setFavouriteIds(StopDataProvider.getFavouriteIds());
+ setRecentIds(StopDataProvider.getRecent());
}, [region]);
// Load stops from network
@@ -169,10 +169,10 @@ export default function StopList() {
try {
setLoading(true);
- const stops = await StopDataProvider.loadStopsFromNetwork(region);
+ const stops = await StopDataProvider.loadStopsFromNetwork();
// Add favourite flags to stops
- const favouriteStopsIds = StopDataProvider.getFavouriteIds(region);
+ const favouriteStopsIds = StopDataProvider.getFavouriteIds();
const stopsWithFavourites = stops.map((stop) => ({
...stop,
favourite: favouriteStopsIds.includes(stop.stopId),
@@ -186,7 +186,7 @@ export default function StopList() {
);
setFavouriteStops(favStops);
- const recIds = StopDataProvider.getRecent(region);
+ const recIds = StopDataProvider.getRecent();
const recStops = recIds
.map((id) => stopsWithFavourites.find((stop) => stop.stopId === id))
.filter(Boolean) as Stop[];
@@ -304,8 +304,8 @@ export default function StopList() {
)}
{!loading && data
? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map(
- (stop) => <StopItem key={stop.stopId} stop={stop} />
- )
+ (stop) => <StopItem key={stop.stopId} stop={stop} />
+ )
: null}
</ul>
</div>
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index df4808d..343cf91 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -15,7 +15,7 @@ import Map, {
type StyleSpecification
} from "react-map-gl/maplibre";
import { StopSheet } from "~/components/StopSheet";
-import { getRegionConfig } from "~/config/RegionConfig";
+import { REGION_DATA } from "~/config/RegionConfig";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp } from "../AppContext";
@@ -40,7 +40,7 @@ export default function StopMap() {
>([]);
const [selectedStop, setSelectedStop] = useState<Stop | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
- const { mapState, updateMapState, theme, region } = useApp();
+ const { mapState, updateMapState, theme } = useApp();
const mapRef = useRef<MapRef>(null);
const [mapStyleKey, setMapStyleKey] = useState<string>("light");
@@ -62,7 +62,7 @@ export default function StopMap() {
};
useEffect(() => {
- StopDataProvider.getStops(region).then((data) => {
+ StopDataProvider.getStops().then((data) => {
const features: GeoJsonFeature<
Point,
{ stopId: number; name: string; lines: string[]; cancelled?: boolean }
@@ -81,7 +81,7 @@ export default function StopMap() {
}));
setStops(features);
});
- }, [region]);
+ }, []);
useEffect(() => {
//const styleName = "carto";
@@ -155,7 +155,7 @@ export default function StopMap() {
const stopId = parseInt(props.stopId, 10);
// fetch full stop to get lines array
- StopDataProvider.getStopById(region, stopId)
+ StopDataProvider.getStopById(stopId)
.then((stop) => {
if (!stop) {
console.warn("Stop not found:", stopId);
@@ -186,14 +186,10 @@ export default function StopMap() {
zoom: mapState.zoom,
}}
attributionControl={{ compact: false }}
- maxBounds={
- getRegionConfig(region).bounds
- ? [getRegionConfig(region).bounds!.sw, getRegionConfig(region).bounds!.ne]
- : undefined
- }
+ maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]}
>
<NavigationControl position="top-right" />
- <GeolocateControl position="top-right" trackUserLocation={true} positionOptions={{enableHighAccuracy: false}} />
+ <GeolocateControl position="top-right" trackUserLocation={true} positionOptions={{ enableHighAccuracy: false }} />
<Source
id="stops-source"
@@ -210,8 +206,8 @@ export default function StopMap() {
"icon-image": [
"case",
["coalesce", ["get", "cancelled"], false],
- `stop-${region}-cancelled`,
- `stop-${region}`,
+ `stop-vigo-cancelled`,
+ `stop-vigo`,
],
"icon-size": [
"interpolate",
@@ -243,19 +239,21 @@ export default function StopMap() {
"text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16],
}}
paint={{
- "text-color": `${getRegionConfig(region).textColour || "#000"}`,
+ "text-color": `${REGION_DATA.textColour}`,
"text-halo-color": "#FFF",
"text-halo-width": 1,
}}
/>
- {selectedStop && (
- <StopSheet
- isOpen={isSheetOpen}
- onClose={() => setIsSheetOpen(false)}
- stop={selectedStop}
- />
- )}
- </Map>
+ {
+ selectedStop && (
+ <StopSheet
+ isOpen={isSheetOpen}
+ onClose={() => setIsSheetOpen(false)}
+ stop={selectedStop}
+ />
+ )
+ }
+ </Map >
);
}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index 351ccf0..faad5a6 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -1,76 +1,41 @@
-import { useState } from "react";
+import { Computer, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { type Theme, useApp } from "../AppContext";
-import { getAvailableRegions } from "../config/RegionConfig";
import "./settings.css";
export default function Settings() {
const { t, i18n } = useTranslation();
usePageTitle(t("navbar.settings", "Ajustes"));
- const navigate = useNavigate();
const {
theme,
setTheme,
- tableStyle,
- setTableStyle,
mapPositionMode,
- setMapPositionMode,
- region,
- setRegion,
+ setMapPositionMode
} = useApp();
- const regions = getAvailableRegions();
- const [showModal, setShowModal] = useState(false);
- const [pendingRegion, setPendingRegion] = useState<string | null>(null);
-
- const handleRegionChange = (newRegion: string) => {
- if (newRegion !== region) {
- setPendingRegion(newRegion);
- setShowModal(true);
- }
- };
-
- const confirmRegionChange = () => {
- if (pendingRegion) {
- setRegion(pendingRegion as any);
- setShowModal(false);
- setPendingRegion(null);
- navigate("/");
- }
- };
-
- const cancelRegionChange = () => {
- setShowModal(false);
- setPendingRegion(null);
- };
-
return (
<div className="page-container">
<section className="settings-section">
<h2>{t("about.settings")}</h2>
- <div className="settings-content-inline">
- <label htmlFor="region" className="form-label-inline">
- Región:
- </label>
- <select
- id="region"
- className="form-select-inline"
- value={region}
- onChange={(e) => handleRegionChange(e.target.value)}
- >
- {regions.map((r) => (
- <option key={r.id} value={r.id}>
- {r.name}
- </option>
- ))}
- </select>
- </div>
+
<div className="settings-content-inline">
<label htmlFor="theme" className="form-label-inline">
{t("about.theme")}
</label>
+
+ <div className="flex">
+ <button onClick={() => setTheme("light")}>
+ <Sun />
+ </button>
+ <button onClick={() => setTheme("dark")}>
+ <Moon />
+ </button>
+ <button onClick={() => setTheme("system")}>
+ <Computer />
+ </button>
+ </div>
+
<select
id="theme"
className="form-select-inline"
@@ -82,6 +47,7 @@ export default function Settings() {
<option value="system">{t("about.theme_system")}</option>
</select>
</div>
+
<div className="settings-content-inline">
<label htmlFor="mapPositionMode" className="form-label-inline">
{t("about.map_position_mode")}
@@ -114,71 +80,7 @@ export default function Settings() {
</select>
</div>
- <div className="settings-content-inline">
- <label htmlFor="tableStyle" className="form-label-inline">
- {t("about.table_style")}
- </label>
- <select
- id="tableStyle"
- className="form-select-inline"
- value={tableStyle}
- onChange={(e) =>
- setTableStyle(
- e.target.value as
- | "regular"
- | "grouped"
- | "experimental_consolidated"
- )
- }
- >
- <option value="regular">{t("about.table_style_regular")}</option>
- <option value="grouped">{t("about.table_style_grouped")}</option>
- <option value="experimental_consolidated">
- {t("about.table_style_experimental_consolidated")}
- </option>
- </select>
- </div>
- <details className="form-details">
- <summary>{t("about.details_summary")}</summary>
- <p>{t("about.details_table")}</p>
- <dl>
- <dt>{t("about.table_style_regular")}</dt>
- <dd>{t("about.details_regular")}</dd>
- <dt>{t("about.table_style_grouped")}</dt>
- <dd>{t("about.details_grouped")}</dd>
- <dt>{t("about.table_style_experimental_consolidated")}</dt>
- <dd>{t("about.details_experimental_consolidated")}</dd>
- </dl>
- </details>
</section>
-
- {showModal && (
- <div className="modal-overlay" onClick={cancelRegionChange}>
- <div className="modal-content" onClick={(e) => e.stopPropagation()}>
- <h2>{t("about.region_change_title", "Cambiar región")}</h2>
- <p>
- {t(
- "about.region_change_message",
- "¿Estás seguro de que quieres cambiar la región? Serás redirigido a la lista de paradas."
- )}
- </p>
- <div className="modal-buttons">
- <button
- className="modal-button modal-button-cancel"
- onClick={cancelRegionChange}
- >
- {t("about.cancel", "Cancelar")}
- </button>
- <button
- className="modal-button modal-button-confirm"
- onClick={confirmRegionChange}
- >
- {t("about.confirm", "Confirmar")}
- </button>
- </div>
- </div>
- </div>
- )}
</div>
);
}
diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css
index 4d204a7..1144584 100644
--- a/src/frontend/app/routes/stops-$id.css
+++ b/src/frontend/app/routes/stops-$id.css
@@ -13,7 +13,7 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
- padding-block: 0 1rem;
+ margin-block: 0 1rem;
}
.table {
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index de552bd..cdc74eb 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { ErrorDisplay } from "~/components/ErrorDisplay";
import LineIcon from "~/components/LineIcon";
+import { PullToRefresh } from "~/components/PullToRefresh";
import { StopAlert } from "~/components/StopAlert";
import { StopMapModal } from "~/components/StopMapModal";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
+import { REGION_DATA } from "~/config/RegionConfig";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
-import { useApp } from "../AppContext";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import "./stops-$id.css";
@@ -53,12 +53,10 @@ interface ErrorInfo {
}
const loadConsolidatedData = async (
- region: RegionId,
stopId: string
): Promise<ConsolidatedCirculation[]> => {
- const regionConfig = getRegionConfig(region);
const resp = await fetch(
- `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
{
headers: {
Accept: "application/json",
@@ -92,8 +90,6 @@ export default function Estimates() {
const [selectedCirculationId, setSelectedCirculationId] = useState<
string | undefined
>(undefined);
- const { region } = useApp();
- const regionConfig = getRegionConfig(region);
// Helper function to get the display name for the stop
const getStopDisplayName = useCallback(() => {
@@ -131,14 +127,14 @@ export default function Estimates() {
setDataLoading(true);
setDataError(null);
- const body = await loadConsolidatedData(region, params.id!);
+ const body = await loadConsolidatedData(params.id!);
setData(body);
setDataDate(new Date());
// Load stop data from StopDataProvider
- const stop = await StopDataProvider.getStopById(region, stopIdNum);
+ const stop = await StopDataProvider.getStopById(stopIdNum);
setStopData(stop);
- setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
+ setCustomName(StopDataProvider.getCustomName(stopIdNum));
} catch (error) {
console.error("Error loading consolidated data:", error);
setDataError(parseError(error));
@@ -147,24 +143,21 @@ export default function Estimates() {
} finally {
setDataLoading(false);
}
- }, [params.id, stopIdNum, region]);
+ }, [params.id, stopIdNum]);
const refreshData = useCallback(async () => {
await Promise.all([loadData()]);
}, [loadData]);
- // 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 refreshData();
} finally {
setIsManualRefreshing(false);
}
}, [refreshData]);
- // Auto-refresh estimates data every 30 seconds (only if not in error state)
useAutoRefresh({
onRefresh: refreshData,
interval: 12000,
@@ -175,18 +168,18 @@ export default function Estimates() {
// Initial load
loadData();
- StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
+ StopDataProvider.pushRecent(parseInt(params.id ?? ""));
setFavourited(
- StopDataProvider.isFavourite(region, parseInt(params.id ?? ""))
+ StopDataProvider.isFavourite(parseInt(params.id ?? ""))
);
- }, [params.id, region, loadData]);
+ }, [params.id, loadData]);
const toggleFavourite = () => {
if (favourited) {
- StopDataProvider.removeFavourite(region, stopIdNum);
+ StopDataProvider.removeFavourite(stopIdNum);
setFavourited(false);
} else {
- StopDataProvider.addFavourite(region, stopIdNum);
+ StopDataProvider.addFavourite(stopIdNum);
setFavourited(true);
}
};
@@ -197,16 +190,16 @@ export default function Estimates() {
if (input === null) return; // cancelled
const trimmed = input.trim();
if (trimmed === "") {
- StopDataProvider.removeCustomName(region, stopIdNum);
+ StopDataProvider.removeCustomName(stopIdNum);
setCustomName(undefined);
} else {
- StopDataProvider.setCustomName(region, stopIdNum, trimmed);
+ StopDataProvider.setCustomName(stopIdNum, trimmed);
setCustomName(trimmed);
}
};
return (
- <>
+ <PullToRefresh onRefresh={handleManualRefresh}>
<div className="page-container stops-page">
<div className="stops-header">
<div>
@@ -238,7 +231,7 @@ export default function Estimates() {
<div className={`estimates-lines-container scrollable`}>
{stopData.lines.map((line) => (
<div key={line} className="estimates-line-icon">
- <LineIcon line={line} region={region} mode="rounded" />
+ <LineIcon line={line} mode="rounded" />
</div>
))}
</div>
@@ -262,7 +255,6 @@ export default function Estimates() {
<ConsolidatedCirculationList
data={data}
dataDate={dataDate}
- regionConfig={regionConfig}
onCirculationClick={(estimate, idx) => {
setSelectedCirculationId(getCirculationId(estimate));
setIsMapModalOpen(true);
@@ -271,11 +263,9 @@ export default function Estimates() {
) : null}
</div>
- {/* Map Modal - only render if we have stop data */}
{stopData && (
<StopMapModal
stop={stopData}
- region={region}
circulations={(data ?? []).map((c) => ({
id: getCirculationId(c),
line: c.line,
@@ -285,8 +275,8 @@ export default function Estimates() {
previousTripShapeId: c.previousTripShapeId,
schedule: c.schedule
? {
- shapeId: c.schedule.shapeId,
- }
+ shapeId: c.schedule.shapeId,
+ }
: undefined,
}))}
isOpen={isMapModalOpen}
@@ -295,6 +285,6 @@ export default function Estimates() {
/>
)}
</div>
- </>
+ </PullToRefresh>
);
}
diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css
deleted file mode 100644
index 3815982..0000000
--- a/src/frontend/app/routes/timetable-$id.css
+++ /dev/null
@@ -1,224 +0,0 @@
-.timetable-full-header {
- margin-bottom: 2rem;
-}
-
-.back-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- margin-bottom: 1rem;
- font-weight: 500;
- transition: color 0.2s ease;
-}
-
-.back-link:hover {
- color: var(--link-hover-color, #0056b3);
- text-decoration: underline;
-}
-
-.back-icon {
- width: 1.2rem;
- height: 1.2rem;
-}
-
-.page-title .stop-name {
- font-size: 1.2rem;
- font-weight: 600;
- color: var(--text-primary, #333);
-}
-
-.page-title .stop-id {
- font-size: 1rem;
- color: var(--text-secondary, #666);
- font-weight: normal;
- margin-left: 0.5rem;
-}
-
-.timetable-full-content {
- margin-top: 1rem;
- position: relative;
- padding-bottom: 80px; /* Space for FAB */
-}
-
-.error-message {
- text-align: center;
- padding: 3rem 2rem;
- background-color: var(--error-background, #f8f9fa);
- border: 1px solid var(--error-border, #dee2e6);
- border-radius: 8px;
- margin: 2rem 0;
-}
-
-.error-message p {
- margin-bottom: 1rem;
- color: var(--error-color, #dc3545);
- font-weight: 500;
-}
-
-.error-detail {
- font-size: 0.9rem;
- color: var(--text-secondary, #666) !important;
- font-weight: normal !important;
-}
-
-.timetable-controls {
- margin-bottom: 1.5rem;
- display: flex;
- justify-content: center;
-}
-
-.past-toggle {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- font-weight: 500;
- padding: 0.5rem 1rem;
- border: 1px solid var(--link-color, #007bff);
- border-radius: 6px;
- background: transparent;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.past-toggle:hover {
- 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 {
- background-color: var(--link-color, #007bff);
- color: white;
- border-color: var(--link-color, #007bff);
-}
-
-.toggle-icon {
- width: 1rem;
- height: 1rem;
-}
-
-/* Next entry highlight */
-.timetable-card.timetable-next {
- border: 2px solid var(--accent-color, #28a745);
- background: var(--surface-next, #e8f5e8) !important;
-}
-
-/* Override timetable cards styles for full page */
-.timetable-full-content .timetable-cards {
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.timetable-full-content .timetable-caption {
- font-size: 1.2rem;
- margin-bottom: 1.5rem;
-}
-
-.timetable-full-content .timetable-card {
- padding: 1.25rem;
-}
-
-/* Responsive design */
-@media (max-width: 768px) {
- .page-title {
- font-size: 1.5rem;
- }
-
- .page-title .stop-name {
- font-size: 1.1rem;
- }
-
- .timetable-full-content .timetable-cards {
- gap: 0.75rem;
- }
-
- .timetable-full-content .timetable-card {
- padding: 1rem;
- }
-}
-
-/* Floating Action Button */
-.fab-container {
- position: fixed;
- bottom: 80px;
- right: 20px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- z-index: 1000;
-}
-
-.fab {
- width: 56px;
- height: 56px;
- border-radius: 50%;
- border: none;
- background-color: var(--button-background-color, #007bff);
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- transition: all 0.3s ease;
- animation: fadeIn 0.3s ease;
-}
-
-.fab:hover {
- background-color: var(--button-hover-background-color, #0069d9);
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
- transform: scale(1.05);
-}
-
-.fab:active {
- transform: scale(0.95);
-}
-
-.fab-icon {
- width: 24px;
- height: 24px;
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Adjust FAB position on mobile */
-@media (max-width: 768px) {
- .fab-container {
- bottom: 70px;
- right: 16px;
- }
-
- .fab {
- width: 48px;
- height: 48px;
- }
-
- .fab-icon {
- width: 20px;
- height: 20px;
- }
-}
diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx
deleted file mode 100644
index c036cb3..0000000
--- a/src/frontend/app/routes/timetable-$id.tsx
+++ /dev/null
@@ -1,570 +0,0 @@
-import {
- ArrowLeft,
- ChevronDown,
- ChevronUp,
- Clock,
- Eye,
- EyeOff,
-} from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Link, useParams } from "react-router";
-import { useApp } from "~/AppContext";
-import { ErrorDisplay } from "~/components/ErrorDisplay";
-import { type ScheduledTable } from "~/components/SchedulesTable";
-import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
-import LineIcon from "../components/LineIcon";
-import StopDataProvider from "../data/StopDataProvider";
-import "./timetable-$id.css";
-
-interface ErrorInfo {
- type: "network" | "server" | "unknown";
- status?: number;
- message?: string;
-}
-
-const loadTimetableData = async (
- region: RegionId,
- stopId: string
-): Promise<ScheduledTable[]> => {
- const regionConfig = getRegionConfig(region);
-
- // Check if timetable is available for this region
- if (!regionConfig.timetableEndpoint) {
- throw new Error("Timetable not available for this region");
- }
-
- // Add delay to see skeletons in action (remove in production)
- await new Promise((resolve) => setTimeout(resolve, 1000));
-
- // Use "today" to let server determine date based on Europe/Madrid timezone
- const resp = await fetch(
- `${regionConfig.timetableEndpoint}?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
-const timeToMinutes = (time: string): number => {
- const [hours, minutes] = time.split(":").map(Number);
- return hours * 60 + minutes;
-};
-
-// Utility function to format GTFS time for display (handle hours >= 24)
-const formatTimeForDisplay = (time: string): string => {
- const [hours, minutes] = time.split(":").map(Number);
- const normalizedHours = hours % 24;
- return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
-};
-
-// Filter past entries (keep only a few recent past ones)
-const filterTimetableData = (
- data: ScheduledTable[],
- currentTime: string,
- showPast: boolean = false
-): ScheduledTable[] => {
- if (showPast) return data;
-
- const currentMinutes = timeToMinutes(currentTime);
- const sortedData = [...data].sort(
- (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
- );
-
- // Find the current position
- const currentIndex = sortedData.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
- );
-
- if (currentIndex === -1) {
- // All entries are in the past, show last 3
- return sortedData.slice(-3);
- }
-
- // Show 3 past entries + all future entries
- const startIndex = Math.max(0, currentIndex - 3);
- return sortedData.slice(startIndex);
-};
-
-// Utility function to parse service ID and get the turn number
-const parseServiceId = (serviceId: string): string => {
- const parts = serviceId.split("_");
- if (parts.length === 0) return "";
-
- const lastPart = parts[parts.length - 1];
- if (lastPart.length < 6) return "";
-
- const last6 = lastPart.slice(-6);
- const lineCode = last6.slice(0, 3);
- const turnCode = last6.slice(-3);
-
- // Remove leading zeros from turn
- const turnNumber = parseInt(turnCode, 10).toString();
-
- // Parse line number with special cases
- const lineNumber = parseInt(lineCode, 10);
- let displayLine: string;
-
- switch (lineNumber) {
- case 1:
- displayLine = "C1";
- break;
- case 3:
- displayLine = "C3";
- break;
- case 30:
- displayLine = "N1";
- break;
- case 33:
- displayLine = "N4";
- break;
- case 8:
- displayLine = "A";
- break;
- case 101:
- displayLine = "H";
- break;
- case 150:
- displayLine = "REF";
- break;
- case 500:
- displayLine = "TUR";
- break;
- default:
- displayLine = `L${lineNumber}`;
- }
-
- return `${displayLine}-${turnNumber}`;
-};
-
-// Scroll threshold for showing FAB buttons (in pixels)
-const SCROLL_THRESHOLD = 100;
-
-export default function Timetable() {
- const { t } = useTranslation();
- const { region } = useApp();
- const params = useParams();
- const stopIdNum = parseInt(params.id ?? "");
- const [timetableData, setTimetableData] = useState<ScheduledTable[]>([]);
- const [customName, setCustomName] = useState<string | undefined>(undefined);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<ErrorInfo | null>(null);
- const [showPastEntries, setShowPastEntries] = useState(false);
- const nextEntryRef = useRef<HTMLDivElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const regionConfig = getRegionConfig(region);
-
- const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS
- const filteredData = filterTimetableData(
- timetableData,
- currentTime,
- showPastEntries
- );
-
- 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 () => {
- // Check if timetable is available for this region
- if (!regionConfig.timetableEndpoint) {
- setError({
- type: "server",
- status: 501,
- message: "Timetable not available for this region",
- });
- setLoading(false);
- return;
- }
-
- try {
- setLoading(true);
- setError(null);
-
- const timetableBody = await loadTimetableData(region, params.id!);
- setTimetableData(timetableBody);
-
- if (timetableBody.length > 0) {
- // Scroll to next entry after a short delay to allow rendering
- setTimeout(() => {
- const currentMinutes = timeToMinutes(currentTime);
- const sortedData = [...timetableBody].sort(
- (a, b) =>
- timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
- );
-
- const nextIndex = sortedData.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
- );
-
- if (nextIndex !== -1 && nextEntryRef.current) {
- nextEntryRef.current.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- }
- }, 500);
- }
- } catch (err) {
- console.error("Error loading timetable data:", err);
- setError(parseError(err));
- setTimetableData([]);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- loadData();
- setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
- }, [params.id, region]);
-
- // Scroll FABs moved to ScrollFabManager component
-
- if (loading) {
- 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>
-
- <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>
- <Link to={`/estimates/${params.id}`} className="back-link">
- <ArrowLeft className="back-icon" />
- {t("timetable.backToEstimates", "Volver a estimaciones")}
- </Link>
- </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>
- {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>
- </div>
- ) : (
- <div className="timetable-full-content" ref={containerRef}>
- <div className="timetable-controls">
- <button
- className={`past-toggle ${showPastEntries ? "active" : ""}`}
- onClick={() => setShowPastEntries(!showPastEntries)}
- >
- {showPastEntries ? (
- <>
- <EyeOff className="toggle-icon" />
- {t("timetable.hidePast", "Ocultar pasados")}
- </>
- ) : (
- <>
- <Eye className="toggle-icon" />
- {t("timetable.showPast", "Mostrar todos")}
- </>
- )}
- </button>
- </div>
-
- <TimetableTableWithScroll
- data={filteredData}
- showAll={true}
- currentTime={currentTime}
- nextEntryRef={nextEntryRef}
- />
-
- {/* Floating Action Button */}
- <ScrollFabManager
- containerRef={containerRef}
- nextEntryRef={nextEntryRef}
- currentTime={currentTime}
- data={filteredData}
- disabled={loading || !!error || timetableData.length === 0}
- />
- </div>
- )}
- </div>
- );
-}
-
-// Custom component for the full timetable with scroll reference
-const TimetableTableWithScroll: React.FC<{
- data: ScheduledTable[];
- showAll: boolean;
- currentTime: string;
- nextEntryRef: React.RefObject<HTMLDivElement | null>;
-}> = ({ data, showAll, currentTime, nextEntryRef }) => {
- const { t } = useTranslation();
- const { region } = useApp();
- const nowMinutes = timeToMinutes(currentTime);
-
- return (
- <div className="timetable-container">
- <div className="timetable-caption">
- {t("timetable.fullCaption", "Horarios teóricos de la parada")}
- </div>
-
- <div className="timetable-cards">
- {data.map((entry, index) => {
- const entryMinutes = timeToMinutes(entry.calling_time);
- const isPast = entryMinutes < nowMinutes;
- const isNext =
- !isPast &&
- (index === 0 ||
- timeToMinutes(data[index - 1]?.calling_time || "00:00:00") <
- nowMinutes);
-
- return (
- <div
- key={`${entry.trip_id}-${index}`}
- ref={isNext ? nextEntryRef : null}
- className={`timetable-card${isPast ? " timetable-past" : ""}${isNext ? " timetable-next" : ""}`}
- style={{
- background: isPast
- ? "var(--surface-past, #f3f3f3)"
- : isNext
- ? "var(--surface-next, #e8f5e8)"
- : "var(--surface-future, #fff)",
- }}
- >
- <div className="card-header">
- <div className="line-info">
- <LineIcon line={entry.line} region={region} />
- </div>
-
- <div className="destination-info">
- {entry.route && entry.route.trim() ? (
- <strong>{entry.route}</strong>
- ) : (
- <strong>
- {t("timetable.noDestination", "Línea")} {entry.line}
- </strong>
- )}
- </div>
-
- <div className="time-info">
- <span className="departure-time">
- {formatTimeForDisplay(entry.calling_time)}
- </span>
- <div className="service-id">
- {parseServiceId(entry.service_id)}
- </div>
- </div>
- </div>
- <div className="card-body">
- {!isPast && entry.next_streets.length > 0 && (
- <div className="route-streets">
- {entry.next_streets.join(" — ")}
- </div>
- )}
- </div>
- </div>
- );
- })}
- </div>
-
- {data.length === 0 && (
- <p className="no-data">
- {t("timetable.noData", "No hay datos de horarios disponibles")}
- </p>
- )}
- </div>
- );
-};
-
-// Component to manage scroll-based FAB visibility globally within timetable
-const ScrollFabManager: React.FC<{
- containerRef: React.RefObject<HTMLDivElement | null>;
- nextEntryRef: React.RefObject<HTMLDivElement | null>;
- currentTime: string;
- data: ScheduledTable[];
- disabled?: boolean;
-}> = ({ containerRef, nextEntryRef, currentTime, data, disabled = false }) => {
- const { t } = useTranslation();
- const [showScrollTop, setShowScrollTop] = useState(false);
- const [showScrollBottom, setShowScrollBottom] = useState(false);
- const [showGoToNow, setShowGoToNow] = useState(false);
-
- // Find the actual scrollable ancestor (.main-content) if our container isn't scrollable
- const getScrollContainer = () => {
- let el: HTMLElement | null = containerRef.current;
- while (el) {
- const style = getComputedStyle(el);
- const hasScroll = el.scrollHeight > el.clientHeight + 8;
- const overflowY = style.overflowY;
- if (hasScroll && (overflowY === "auto" || overflowY === "scroll")) {
- return el;
- }
- el = el.parentElement;
- }
- return null;
- };
-
- useEffect(() => {
- if (disabled) return;
- const scrollEl = getScrollContainer();
- const useWindowScroll = !scrollEl;
-
- const handleScroll = () => {
- const scrollTop = useWindowScroll
- ? window.scrollY || document.documentElement.scrollTop || 0
- : scrollEl!.scrollTop;
- const scrollHeight = useWindowScroll
- ? document.documentElement.scrollHeight
- : scrollEl!.scrollHeight;
- const clientHeight = useWindowScroll
- ? window.innerHeight
- : scrollEl!.clientHeight;
-
- const scrollBottom = scrollHeight - scrollTop - clientHeight;
- const threshold = 80; // slightly smaller threshold for responsiveness
- setShowScrollTop(scrollTop > threshold);
- setShowScrollBottom(scrollBottom > threshold);
-
- if (nextEntryRef.current) {
- const rect = nextEntryRef.current.getBoundingClientRect();
- const isNextVisible =
- rect.top >= 0 && rect.bottom <= window.innerHeight;
- setShowGoToNow(!isNextVisible);
- }
- };
-
- const target: any = useWindowScroll ? window : scrollEl!;
- target.addEventListener("scroll", handleScroll, { passive: true });
- window.addEventListener("resize", handleScroll);
- handleScroll();
- return () => {
- target.removeEventListener("scroll", handleScroll);
- window.removeEventListener("resize", handleScroll);
- };
- }, [containerRef, nextEntryRef, disabled, data, currentTime]);
-
- const scrollToTop = () => {
- const scrollEl = getScrollContainer();
- if (!scrollEl) {
- window.scrollTo({ top: 0, behavior: "smooth" });
- } else {
- scrollEl.scrollTo({ top: 0, behavior: "smooth" });
- }
- };
- const scrollToBottom = () => {
- const scrollEl = getScrollContainer();
- if (!scrollEl) {
- window.scrollTo({
- top: document.documentElement.scrollHeight,
- behavior: "smooth",
- });
- } else {
- scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: "smooth" });
- }
- };
- const scrollToNow = () => {
- nextEntryRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- };
-
- if (disabled) return null;
- if (!(showGoToNow || showScrollTop || showScrollBottom)) return null;
-
- return (
- <div className="fab-container">
- {showGoToNow && !showScrollTop && !showScrollBottom && (
- <button
- className="fab fab-now"
- onClick={scrollToNow}
- title={t("timetable.goToNow", "Ir a ahora")}
- aria-label={t("timetable.goToNow", "Ir a ahora")}
- >
- <Clock className="fab-icon" />
- </button>
- )}
- {showScrollTop && (
- <button
- className="fab fab-up"
- onClick={scrollToTop}
- title={t("timetable.scrollUp", "Subir")}
- aria-label={t("timetable.scrollUp", "Subir")}
- >
- <ChevronUp className="fab-icon" />
- </button>
- )}
- {showScrollBottom && !showScrollTop && (
- <button
- className="fab fab-down"
- onClick={scrollToBottom}
- title={t("timetable.scrollDown", "Bajar")}
- aria-label={t("timetable.scrollDown", "Bajar")}
- >
- <ChevronDown className="fab-icon" />
- </button>
- )}
- </div>
- );
-};