diff options
Diffstat (limited to 'src/frontend/app/components')
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 && <> · </>} + + {estimate.realTime && estimate.realTime.distance >= 0 && ( + <>{formatDistance(estimate.realTime.distance)}</> + )} + + {estimate.schedule && + estimate.realTime && + estimate.realTime.distance >= 0 && <> · </>} + + {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 && <> · </>} - - {estimate.realTime && estimate.realTime.distance >= 0 && ( - <>{formatDistance(estimate.realTime.distance)}</> - )} - - {estimate.schedule && - estimate.realTime && - estimate.realTime.distance >= 0 && <> · </>} - - {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> ))} |
