aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/ErrorDisplay.tsx8
-rw-r--r--src/frontend/app/components/GroupedTable.tsx4
-rw-r--r--src/frontend/app/components/NavBar.tsx2
-rw-r--r--src/frontend/app/components/PullToRefresh.tsx8
-rw-r--r--src/frontend/app/components/RegularTable.tsx2
-rw-r--r--src/frontend/app/components/SchedulesTable.tsx6
-rw-r--r--src/frontend/app/components/StopMapSheet.css27
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx151
-rw-r--r--src/frontend/app/components/StopSheet.tsx105
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx180
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css5
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx174
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx6
13 files changed, 389 insertions, 289 deletions
diff --git a/src/frontend/app/components/ErrorDisplay.tsx b/src/frontend/app/components/ErrorDisplay.tsx
index f63c995..a2f40c8 100644
--- a/src/frontend/app/components/ErrorDisplay.tsx
+++ b/src/frontend/app/components/ErrorDisplay.tsx
@@ -38,25 +38,25 @@ export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
case "network":
return t(
"errors.network",
- "No hay conexión a internet. Comprueba tu conexión y vuelve a intentarlo.",
+ "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.",
+ "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.",
+ "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.",
+ "Error en la solicitud. Verifica que la parada existe."
);
}
return t("errors.server_generic", "Error del servidor ({{status}})", {
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
index f116537..3a799a7 100644
--- a/src/frontend/app/components/GroupedTable.tsx
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -29,7 +29,7 @@ export const GroupedTable: React.FC<GroupedTable> = ({
acc[estimate.line].push(estimate);
return acc;
},
- {} as Record<string, typeof data>,
+ {} as Record<string, typeof data>
);
const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
@@ -72,7 +72,7 @@ export const GroupedTable: React.FC<GroupedTable> = ({
</td>
)}
</tr>
- )),
+ ))
)}
</tbody>
diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx
index f9f1a03..b8c6ad6 100644
--- a/src/frontend/app/components/NavBar.tsx
+++ b/src/frontend/app/components/NavBar.tsx
@@ -51,7 +51,7 @@ export default function NavBar() {
updateMapState(coords, 16);
}
},
- () => {},
+ () => {}
);
},
},
diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx
index d5ea51b..b3abe86 100644
--- a/src/frontend/app/components/PullToRefresh.tsx
+++ b/src/frontend/app/components/PullToRefresh.tsx
@@ -48,7 +48,7 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
htmlScroll,
bodyScroll,
containerScroll,
- parentScroll,
+ parentScroll
);
if (maxScroll > 0 || isRefreshing) {
@@ -60,7 +60,7 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
startY.current = e.touches[0].clientY;
setIsPulling(true);
},
- [isRefreshing],
+ [isRefreshing]
);
const handleTouchMove = useCallback(
@@ -78,7 +78,7 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
htmlScroll,
bodyScroll,
containerScroll,
- parentScroll,
+ parentScroll
);
if (maxScroll > 10) {
@@ -116,7 +116,7 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
setIsActive(false);
}
},
- [isPulling, threshold, isActive, y],
+ [isPulling, threshold, isActive, y]
);
const handleTouchEnd = useCallback(async () => {
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
index baa3804..868332f 100644
--- a/src/frontend/app/components/RegularTable.tsx
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -24,7 +24,7 @@ export const RegularTable: React.FC<RegularTableProps> = ({
{
hour: "2-digit",
minute: "2-digit",
- },
+ }
).format(arrival);
};
diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx
index 07d3136..60e7ab0 100644
--- a/src/frontend/app/components/SchedulesTable.tsx
+++ b/src/frontend/app/components/SchedulesTable.tsx
@@ -100,17 +100,17 @@ const findNearbyEntries = (
entries: ScheduledTable[],
currentTime: string,
before: number = 4,
- after: number = 4,
+ after: number = 4
): ScheduledTable[] => {
if (!currentTime) return entries.slice(0, before + after);
const currentMinutes = timeToMinutes(currentTime);
const sortedEntries = [...entries].sort(
- (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time),
+ (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
);
let currentIndex = sortedEntries.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes,
+ (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
);
if (currentIndex === -1) {
diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css
index 5125ff0..4b0f528 100644
--- a/src/frontend/app/components/StopMapSheet.css
+++ b/src/frontend/app/components/StopMapSheet.css
@@ -27,14 +27,18 @@
.center-btn {
appearance: none;
- border: 1px solid rgba(0,0,0,0.15);
- background: color-mix(in oklab, var(--background-color, #fff) 85%, transparent);
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ background: color-mix(
+ in oklab,
+ var(--background-color, #fff) 85%,
+ transparent
+ );
color: var(--text-primary, #111);
padding: 6px;
border-radius: 6px;
font-size: 12px;
line-height: 1;
- box-shadow: 0 1px 2px rgba(0,0,0,0.15);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
@@ -56,7 +60,7 @@
background: #2a6df4;
border: 2px solid #fff;
border-radius: 50%;
- box-shadow: 0 1px 2px rgba(0,0,0,0.3);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.user-dot__pulse {
@@ -73,7 +77,16 @@
}
@keyframes userPulse {
- 0% { transform: scale(0.6); opacity: 0.8; }
- 70% { transform: scale(1.2); opacity: 0.15; }
- 100% { transform: scale(1.4); opacity: 0; }
+ 0% {
+ transform: scale(0.6);
+ opacity: 0.8;
+ }
+ 70% {
+ transform: scale(1.2);
+ opacity: 0.15;
+ }
+ 100% {
+ transform: scale(1.4);
+ opacity: 0;
+ }
}
diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx
index e87e8c8..b3a1666 100644
--- a/src/frontend/app/components/StopMapSheet.tsx
+++ b/src/frontend/app/components/StopMapSheet.tsx
@@ -1,6 +1,10 @@
import maplibregl from "maplibre-gl";
import React, { useEffect, useMemo, useRef, useState } from "react";
-import Map, { AttributionControl, Marker, type MapRef } from "react-map-gl/maplibre";
+import Map, {
+ AttributionControl,
+ Marker,
+ type MapRef,
+} from "react-map-gl/maplibre";
import { useApp } from "~/AppContext";
import { getLineColor } from "~/data/LineColors";
import type { RegionId } from "~/data/RegionConfig";
@@ -53,24 +57,38 @@ export const StopMap: React.FC<StopMapProps> = ({
const lat2 = (b.lat * Math.PI) / 180;
const sinDLat = Math.sin(dLat / 2);
const sinDLon = Math.sin(dLon / 2);
- const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
+ const h =
+ sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
};
const computeFocusPoints = (): Pt[] => {
const buses: Pt[] = [];
for (const c of busPositions) {
- if (c.currentPosition) buses.push({ lat: c.currentPosition.latitude, lon: c.currentPosition.longitude });
+ if (c.currentPosition)
+ buses.push({
+ lat: c.currentPosition.latitude,
+ lon: c.currentPosition.longitude,
+ });
}
- const stopPt = stop.latitude && stop.longitude ? { lat: stop.latitude, lon: stop.longitude } : null;
- const userPt = userPosition ? { lat: userPosition.latitude, lon: userPosition.longitude } : null;
+ const stopPt =
+ stop.latitude && stop.longitude
+ ? { lat: stop.latitude, lon: stop.longitude }
+ : null;
+ const userPt = userPosition
+ ? { lat: userPosition.latitude, lon: userPosition.longitude }
+ : null;
if (buses.length === 0 && !stopPt && !userPt) return [];
// Choose anchor for proximity: stop > user > average of buses
let anchor: Pt | null = stopPt || userPt || null;
if (!anchor && buses.length) {
- let lat = 0, lon = 0;
- for (const b of buses) { lat += b.lat; lon += b.lon; }
+ let lat = 0,
+ lon = 0;
+ for (const b of buses) {
+ lat += b.lat;
+ lon += b.lon;
+ }
anchor = { lat: lat / buses.length, lon: lon / buses.length };
}
@@ -122,7 +140,7 @@ export const StopMap: React.FC<StopMapProps> = ({
});
},
() => {},
- { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 },
+ { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 }
);
geoWatchId.current = navigator.geolocation.watchPosition(
(pos) => {
@@ -133,7 +151,7 @@ export const StopMap: React.FC<StopMapProps> = ({
});
},
() => {},
- { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 },
+ { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 }
);
} catch {}
return () => {
@@ -158,7 +176,7 @@ export const StopMap: React.FC<StopMapProps> = ({
const busPositions = useMemo(
() => circulations.filter((c) => !!c.currentPosition),
- [circulations],
+ [circulations]
);
// Fit bounds to stop + buses, with ~1km padding each side, with a modest animation
@@ -185,13 +203,17 @@ export const StopMap: React.FC<StopMapProps> = ({
const bounds = new maplibregl.LngLatBounds(sw, ne);
// Determine predominant bus quadrant relative to stop to bias padding.
- const padding: number | { top: number; right: number; bottom: number; left: number } = 24;
+ const padding:
+ | number
+ | { top: number; right: number; bottom: number; left: number } = 24;
// If the diagonal is huge (likely outliers sneaked in), clamp via zoom fallback
try {
if (points.length === 1) {
const only = points[0];
- mapRef.current.getMap().jumpTo({ center: [only.lon, only.lat], zoom: 16 });
+ mapRef.current
+ .getMap()
+ .jumpTo({ center: [only.lon, only.lat], zoom: 16 });
} else {
mapRef.current.fitBounds(bounds, {
padding: padding as any,
@@ -208,7 +230,10 @@ export const StopMap: React.FC<StopMapProps> = ({
const pts = computeFocusPoints();
if (pts.length === 0) return;
- let minLat = pts[0].lat, maxLat = pts[0].lat, minLon = pts[0].lon, maxLon = pts[0].lon;
+ let minLat = pts[0].lat,
+ maxLat = pts[0].lat,
+ minLon = pts[0].lon,
+ maxLon = pts[0].lon;
for (const p of pts) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
@@ -220,14 +245,22 @@ export const StopMap: React.FC<StopMapProps> = ({
const ne = [maxLon, maxLat] as [number, number];
const bounds = new maplibregl.LngLatBounds(sw, ne);
- const padding: number | { top: number; right: number; bottom: number; left: number } = 24;
+ const padding:
+ | number
+ | { top: number; right: number; bottom: number; left: number } = 24;
try {
if (pts.length === 1) {
const only = pts[0];
- mapRef.current.getMap().easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 });
+ mapRef.current
+ .getMap()
+ .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 });
} else {
- mapRef.current.fitBounds(bounds, { padding: padding as any, duration: 500, maxZoom: 17 } as any);
+ mapRef.current.fitBounds(bounds, {
+ padding: padding as any,
+ duration: 500,
+ maxZoom: 17,
+ } as any);
}
} catch {}
};
@@ -256,15 +289,36 @@ export const StopMap: React.FC<StopMapProps> = ({
{/* Stop marker (center) */}
{stop.latitude && stop.longitude && (
- <Marker longitude={stop.longitude} latitude={stop.latitude} anchor="bottom">
+ <Marker
+ longitude={stop.longitude}
+ latitude={stop.latitude}
+ anchor="bottom"
+ >
<div title={`Stop ${stop.stopId}`}>
<svg width="28" height="36" viewBox="0 0 28 36">
<defs>
- <filter id="drop" x="-20%" y="-20%" width="140%" height="140%">
- <feDropShadow dx="0" dy="1" stdDeviation="1" flood-opacity="0.35" />
+ <filter
+ id="drop"
+ x="-20%"
+ y="-20%"
+ width="140%"
+ height="140%"
+ >
+ <feDropShadow
+ dx="0"
+ dy="1"
+ stdDeviation="1"
+ floodOpacity={0.35}
+ />
</filter>
</defs>
- <path d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z" fill="#1976d2" stroke="#fff" strokeWidth="2" filter="url(#drop)" />
+ <path
+ d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z"
+ fill="#1976d2"
+ stroke="#fff"
+ strokeWidth="2"
+ filter="url(#drop)"
+ />
<circle cx="14" cy="13" r="5" fill="#fff" />
<circle cx="14" cy="13" r="3" fill="#1976d2" />
</svg>
@@ -274,7 +328,11 @@ export const StopMap: React.FC<StopMapProps> = ({
{/* User position marker (if available) */}
{userPosition && (
- <Marker longitude={userPosition.longitude} latitude={userPosition.latitude} anchor="center">
+ <Marker
+ longitude={userPosition.longitude}
+ latitude={userPosition.latitude}
+ anchor="center"
+ >
<div className="user-dot" title="Your location">
<div className="user-dot__pulse" />
<div className="user-dot__core" />
@@ -290,7 +348,12 @@ export const StopMap: React.FC<StopMapProps> = ({
const gaps: number[] = new Array(busPositions.length).fill(baseGap);
if (map && zoom >= 14.5 && busPositions.length > 1) {
const pts = busPositions.map((c) =>
- c.currentPosition ? map.project([c.currentPosition.longitude, c.currentPosition.latitude]) : null,
+ c.currentPosition
+ ? map.project([
+ c.currentPosition.longitude,
+ c.currentPosition.latitude,
+ ])
+ : null
);
for (let i = 0; i < pts.length; i++) {
const pi = pts[i];
@@ -314,7 +377,12 @@ export const StopMap: React.FC<StopMapProps> = ({
const showLabel = zoom >= 13;
const labelGap = gaps[idx] ?? baseGap;
return (
- <Marker key={idx} longitude={p.longitude} latitude={p.latitude} anchor="center">
+ <Marker
+ key={idx}
+ longitude={p.longitude}
+ latitude={p.latitude}
+ anchor="center"
+ >
<div
style={{
display: "flex",
@@ -329,9 +397,16 @@ export const StopMap: React.FC<StopMapProps> = ({
width="20"
height="20"
viewBox="0 0 24 24"
- style={{ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))" }}
+ style={{
+ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))",
+ }}
>
- <path d="M12 2 L20 22 L12 18 L4 22 Z" fill={lineColor.background} stroke="#fff" strokeWidth="1.5" />
+ <path
+ d="M12 2 L20 22 L12 18 L4 22 Z"
+ fill={lineColor.background}
+ stroke="#fff"
+ strokeWidth="1.5"
+ />
</svg>
{showLabel && (
<div
@@ -362,11 +437,29 @@ export const StopMap: React.FC<StopMapProps> = ({
)}
{/* Floating controls */}
<div className="map-floating-controls">
- <button type="button" aria-label="Center" className="center-btn" onClick={handleCenter} title="Center view">
+ <button
+ type="button"
+ aria-label="Center"
+ className="center-btn"
+ onClick={handleCenter}
+ title="Center view"
+ >
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
- <circle cx="12" cy="12" r="3" fill="currentColor"/>
- <path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
- <circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.5"/>
+ <circle cx="12" cy="12" r="3" fill="currentColor" />
+ <path
+ d="M12 2v3M12 19v3M2 12h3M19 12h3"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ />
+ <circle
+ cx="12"
+ cy="12"
+ r="8"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ />
</svg>
</button>
</div>
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
index 6977d87..2a28d36 100644
--- a/src/frontend/app/components/StopSheet.tsx
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -1,15 +1,16 @@
-import { Clock, RefreshCw } from "lucide-react";
+import { RefreshCw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sheet } from "react-modal-sheet";
import { Link } from "react-router";
import type { Stop } from "~/data/StopDataProvider";
import { useApp } from "../AppContext";
-import { REGIONS, type RegionId, getRegionConfig } from "../data/RegionConfig";
-import { type Estimate } from "../routes/estimates-$id";
+import { type RegionId, getRegionConfig } from "../data/RegionConfig";
+import { type ConsolidatedCirculation } from "../routes/stops-$id";
import { ErrorDisplay } from "./ErrorDisplay";
import LineIcon from "./LineIcon";
import { StopAlert } from "./StopAlert";
+import { ConsolidatedCirculationCard } from "./Stops/ConsolidatedCirculationCard";
import "./StopSheet.css";
import { StopSheetSkeleton } from "./StopSheetSkeleton";
@@ -25,16 +26,19 @@ interface ErrorInfo {
message?: string;
}
-const loadStopData = async (
+const loadConsolidatedData = async (
region: RegionId,
- stopId: number,
-): Promise<Estimate[]> => {
+ stopId: number
+): Promise<ConsolidatedCirculation[]> => {
const regionConfig = getRegionConfig(region);
- const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, {
- headers: {
- Accept: "application/json",
- },
- });
+ const resp = await fetch(
+ `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ {
+ headers: {
+ Accept: "application/json",
+ },
+ }
+ );
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
@@ -50,7 +54,8 @@ export const StopSheet: React.FC<StopSheetProps> = ({
}) => {
const { t } = useTranslation();
const { region } = useApp();
- const [data, setData] = useState<Estimate[] | null>(null);
+ const regionConfig = getRegionConfig(region);
+ const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -82,7 +87,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
setError(null);
setData(null);
- const stopData = await loadStopData(region, stop.stopId);
+ const stopData = await loadConsolidatedData(region, stop.stopId);
setData(stopData);
setLastUpdated(new Date());
} catch (err) {
@@ -99,33 +104,15 @@ export const StopSheet: React.FC<StopSheetProps> = ({
}
}, [isOpen, stop.stopId, region]);
- const formatTime = (minutes: number) => {
- if (minutes > 15) {
- const now = new Date();
- const arrival = new Date(now.getTime() + minutes * 60000);
- return Intl.DateTimeFormat(
- typeof navigator !== "undefined" ? navigator.language : "en",
- {
- hour: "2-digit",
- minute: "2-digit",
- },
- ).format(arrival);
- } else {
- return `${minutes} ${t("estimates.minutes", "min")}`;
- }
- };
-
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} ${t("estimates.meters", "m")}`;
- }
- };
-
// Show only the next 4 arrivals
- const limitedEstimates =
- data?.sort((a, b) => a.minutes - b.minutes).slice(0, 4) || [];
+ const sortedData = data
+ ? [...data].sort(
+ (a, b) =>
+ (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
+ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
+ )
+ : [];
+ const limitedEstimates = sortedData.slice(0, 4);
return (
<Sheet isOpen={isOpen} onClose={onClose} detent="content">
@@ -158,7 +145,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
onRetry={loadData}
title={t(
"errors.estimates_title",
- "Error al cargar estimaciones",
+ "Error al cargar estimaciones"
)}
className="compact"
/>
@@ -176,36 +163,15 @@ export const StopSheet: React.FC<StopSheetProps> = ({
) : (
<div className="stop-sheet-estimates-list">
{limitedEstimates.map((estimate, idx) => (
- <div key={idx} className="stop-sheet-estimate-item">
- <div className="stop-sheet-estimate-line">
- <LineIcon line={estimate.line} region={region} />
- </div>
- <div className="stop-sheet-estimate-details">
- <div className="stop-sheet-estimate-route">
- {estimate.route}
- </div>
- </div>
- <div className="stop-sheet-estimate-arrival">
- <div
- className={`stop-sheet-estimate-time ${estimate.minutes <= 15 ? "is-minutes" : ""}`}
- >
- <Clock />
- {formatTime(estimate.minutes)}
- </div>
- {REGIONS[region].showMeters &&
- estimate.meters >= 0 && (
- <div className="stop-sheet-estimate-distance">
- {formatDistance(estimate.meters)}
- </div>
- )}
- </div>
- </div>
+ <ConsolidatedCirculationCard
+ key={idx}
+ estimate={estimate}
+ regionConfig={regionConfig}
+ />
))}
</div>
)}
</div>
-
-
</>
) : null}
</div>
@@ -236,14 +202,11 @@ export const StopSheet: React.FC<StopSheetProps> = ({
</button>
<Link
- to={`/estimates/${stop.stopId}`}
+ to={`/stops/${stop.stopId}`}
className="stop-sheet-view-all"
onClick={onClose}
>
- {t(
- "map.view_all_estimates",
- "Ver todas las estimaciones",
- )}
+ {t("map.view_all_estimates", "Ver todas las estimaciones")}
</Link>
</div>
</div>
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
new file mode 100644
index 0000000..8c3e922
--- /dev/null
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
@@ -0,0 +1,180 @@
+import { Clock } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import LineIcon from "~components/LineIcon";
+import { type RegionConfig } from "~data/RegionConfig";
+import { type ConsolidatedCirculation } from "~routes/stops-$id";
+
+import "./ConsolidatedCirculationList.css";
+
+interface ConsolidatedCirculationCardProps {
+ estimate: ConsolidatedCirculation;
+ regionConfig: RegionConfig;
+}
+
+// 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;
+ case 201:
+ displayLine = "U1";
+ break;
+ case 202:
+ displayLine = "U2";
+ break;
+ default:
+ displayLine = `L${lineNumber}`;
+ }
+
+ return `${displayLine}-${turnNumber}`;
+};
+
+export const ConsolidatedCirculationCard: React.FC<
+ ConsolidatedCirculationCardProps
+> = ({ estimate, regionConfig }) => {
+ const { t } = useTranslation();
+
+ const absoluteArrivalTime = (minutes: number) => {
+ const now = new Date();
+ const arrival = new Date(now.getTime() + minutes * 60000);
+ return Intl.DateTimeFormat(
+ typeof navigator !== "undefined" ? navigator.language : "en",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ }
+ ).format(arrival);
+ };
+
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
+ }
+ };
+
+ const getDelayText = (estimate: ConsolidatedCirculation): string | null => {
+ if (!estimate.schedule || !estimate.realTime) {
+ return null;
+ }
+
+ const delay = estimate.realTime.minutes - estimate.schedule.minutes;
+
+ if (delay >= -1 && delay <= 2) {
+ return "OK";
+ } else if (delay > 2) {
+ return "R" + delay;
+ } else {
+ return "A" + Math.abs(delay);
+ }
+ };
+
+ const getTripIdDisplay = (tripId: string): string => {
+ const parts = tripId.split("_");
+ return parts.length > 1 ? parts[1] : tripId;
+ };
+
+ const getTimeClass = (estimate: ConsolidatedCirculation): string => {
+ if (estimate.realTime && estimate.schedule?.running) {
+ return "time-running";
+ }
+
+ if (estimate.realTime && !estimate.schedule) {
+ return "time-running";
+ } else if (estimate.realTime && !estimate.schedule?.running) {
+ return "time-delayed";
+ }
+
+ return "time-scheduled";
+ };
+
+ const displayMinutes =
+ estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0;
+ const timeClass = getTimeClass(estimate);
+ const delayText = getDelayText(estimate);
+
+ return (
+ <div className="consolidated-circulation-card">
+ <div className="card-header">
+ <div className="line-info">
+ <LineIcon line={estimate.line} region={regionConfig.id} />
+ </div>
+
+ <div className="route-info">
+ <strong>{estimate.route}</strong>
+ </div>
+
+ <div className="time-info">
+ <div className={`arrival-time ${timeClass}`}>
+ <Clock />
+ {estimate.realTime
+ ? `${displayMinutes} ${t("estimates.minutes", "min")}`
+ : absoluteArrivalTime(displayMinutes)}
+ </div>
+ <div className="distance-info">
+ {estimate.schedule && (
+ <>
+ {parseServiceId(estimate.schedule.serviceId)} (
+ {getTripIdDisplay(estimate.schedule.tripId)})
+ </>
+ )}
+
+ {estimate.schedule &&
+ estimate.realTime &&
+ estimate.realTime.distance >= 0 && <> &middot; </>}
+
+ {estimate.realTime && estimate.realTime.distance >= 0 && (
+ <>{formatDistance(estimate.realTime.distance)}</>
+ )}
+
+ {estimate.schedule &&
+ estimate.realTime &&
+ estimate.realTime.distance >= 0 && <> &middot; </>}
+
+ {delayText}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
index 4d6a3a8..939f40d 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
@@ -92,7 +92,10 @@
}
[data-theme="dark"] .consolidated-circulation-card .arrival-time.time-scheduled,
-[data-theme="dark"] .consolidated-circulation-card .arrival-time.time-scheduled svg {
+[data-theme="dark"]
+ .consolidated-circulation-card
+ .arrival-time.time-scheduled
+ svg {
color: #8fb4ff; /* lighten for dark backgrounds */
}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
index 4ee296d..d95ee03 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -1,8 +1,7 @@
-import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
-import LineIcon from "~components/LineIcon";
import { type RegionConfig } from "~data/RegionConfig";
import { type ConsolidatedCirculation } from "~routes/stops-$id";
+import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard";
import "./ConsolidatedCirculationList.css";
@@ -12,63 +11,6 @@ interface RegularTableProps {
regionConfig: RegionConfig;
}
-// 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;
- case 201:
- displayLine = "U1";
- break;
- case 202:
- displayLine = "U2";
- break;
- default:
- displayLine = `L${lineNumber}`;
- }
-
- return `${displayLine}-${turnNumber}`;
-};
-
export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
data,
dataDate,
@@ -76,65 +18,10 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
}) => {
const { t } = useTranslation();
- const absoluteArrivalTime = (minutes: number) => {
- const now = new Date();
- const arrival = new Date(now.getTime() + minutes * 60000);
- return Intl.DateTimeFormat(
- typeof navigator !== "undefined" ? navigator.language : "en",
- {
- hour: "2-digit",
- minute: "2-digit",
- },
- ).format(arrival);
- };
-
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} ${t("estimates.meters", "m")}`;
- }
- };
-
- const getDelayText = (estimate: ConsolidatedCirculation): string | null => {
- if (!estimate.schedule || !estimate.realTime) {
- return null;
- }
-
- const delay = estimate.realTime.minutes - estimate.schedule.minutes;
-
- if (delay >= -1 && delay <= 2) {
- return "OK"
- } else if (delay > 2) {
- return "R" + delay;
- } else {
- return "A" + Math.abs(delay);
- }
- };
-
- const getTripIdDisplay = (tripId: string): string => {
- const parts = tripId.split("_");
- return parts.length > 1 ? parts[1] : tripId;
- };
-
- const getTimeClass = (estimate: ConsolidatedCirculation): string => {
- if (estimate.realTime && estimate.schedule?.running) {
- return "time-running";
- }
-
- if (estimate.realTime && !estimate.schedule) {
- return "time-running";
- } else if (estimate.realTime && !estimate.schedule?.running) {
- return "time-delayed";
- }
-
- return "time-scheduled";
- };
-
const sortedData = [...data].sort(
(a, b) =>
(a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
- (b.realTime?.minutes ?? b.schedule?.minutes ?? 999),
+ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
);
return (
@@ -151,56 +38,13 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
</div>
) : (
<>
- {sortedData.map((estimate, idx) => {
- const displayMinutes =
- estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0;
- const timeClass = getTimeClass(estimate);
- const delayText = getDelayText(estimate);
-
- return (
- <div key={idx} className="consolidated-circulation-card">
- <div className="card-header">
- <div className="line-info">
- <LineIcon line={estimate.line} region={regionConfig.id} />
- </div>
-
- <div className="route-info">
- <strong>{estimate.route}</strong>
- </div>
-
- <div className="time-info">
- <div className={`arrival-time ${timeClass}`}>
- <Clock />
- {estimate.realTime
- ? `${displayMinutes} ${t("estimates.minutes", "min")}`
- : absoluteArrivalTime(displayMinutes)}
- </div>
- <div className="distance-info">
- {estimate.schedule && (
- <>
- {parseServiceId(estimate.schedule.serviceId)} ({getTripIdDisplay(estimate.schedule.tripId)})
- </>
- )}
-
- {estimate.schedule &&
- estimate.realTime &&
- estimate.realTime.distance >= 0 && <> &middot; </>}
-
- {estimate.realTime && estimate.realTime.distance >= 0 && (
- <>{formatDistance(estimate.realTime.distance)}</>
- )}
-
- {estimate.schedule &&
- estimate.realTime &&
- estimate.realTime.distance >= 0 && <> &middot; </>}
-
- {delayText}
- </div>
- </div>
- </div>
- </div>
- );
- })}
+ {sortedData.map((estimate, idx) => (
+ <ConsolidatedCirculationCard
+ key={idx}
+ estimate={estimate}
+ regionConfig={regionConfig}
+ />
+ ))}
</>
)}
</>
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx
index 43f02ca..90d92e2 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx
@@ -34,7 +34,11 @@ export const ConsolidatedCirculationListSkeleton: React.FC = () => {
<div className="card-footer">
<Skeleton width="90%" height={14} />
- <Skeleton width="70%" height={14} style={{ marginTop: "4px" }} />
+ <Skeleton
+ width="70%"
+ height={14}
+ style={{ marginTop: "4px" }}
+ />
</div>
</div>
))}