From 3227c7bc6bd233c92b1cf54bec689f0582dca547 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 1 Dec 2025 22:25:56 +0100 Subject: refactor: replace StopSheet with StopSummarySheet and update related components - Deleted StopSheet and StopSheetSkeleton components. - Introduced StopSummarySheet and StopSummarySheetSkeleton components. - Updated ConsolidatedCirculationCard to support a reduced view. - Modified ConsolidatedCirculationList to accept a reduced prop. - Adjusted map route to use StopSummarySheet. - Cleaned up CSS styles related to the stop sheet components. - Enhanced error handling and loading states in the new summary sheet. - Updated stop report logic to filter out empty next streets. --- src/frontend/app/components/StopMapSheet.css | 149 ------ src/frontend/app/components/StopMapSheet.tsx | 575 --------------------- src/frontend/app/components/StopSheet.css | 309 ----------- src/frontend/app/components/StopSheet.tsx | 213 -------- src/frontend/app/components/StopSheetSkeleton.tsx | 79 --- src/frontend/app/components/StopSummarySheet.css | 309 +++++++++++ src/frontend/app/components/StopSummarySheet.tsx | 208 ++++++++ .../app/components/StopSummarySheetSkeleton.tsx | 79 +++ .../Stops/ConsolidatedCirculationCard.tsx | 168 ++++-- .../Stops/ConsolidatedCirculationList.tsx | 30 +- 10 files changed, 737 insertions(+), 1382 deletions(-) delete mode 100644 src/frontend/app/components/StopMapSheet.css delete mode 100644 src/frontend/app/components/StopMapSheet.tsx delete mode 100644 src/frontend/app/components/StopSheet.css delete mode 100644 src/frontend/app/components/StopSheet.tsx delete mode 100644 src/frontend/app/components/StopSheetSkeleton.tsx create mode 100644 src/frontend/app/components/StopSummarySheet.css create mode 100644 src/frontend/app/components/StopSummarySheet.tsx create mode 100644 src/frontend/app/components/StopSummarySheetSkeleton.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css deleted file mode 100644 index 7c96c2b..0000000 --- a/src/frontend/app/components/StopMapSheet.css +++ /dev/null @@ -1,149 +0,0 @@ -/* Stop map container */ -.stop-map-container { - width: 100%; - height: 50vh; - overflow: hidden; - border: 1px solid var(--border-color); - margin-block-start: 0; - flex-shrink: 0; - position: relative; -} - -@media (max-width: 640px) { - .stop-map-container { - height: 30vh; - } -} - -/* Floating controls */ -.map-floating-controls { - position: absolute; - left: 8px; - top: 8px; - display: flex; - gap: 8px; - z-index: 2; -} - -.center-btn { - appearance: none; - 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); - cursor: pointer; -} - -/* User location dot */ -.user-dot { - position: relative; - width: 22px; - height: 22px; -} - -.user-dot__core { - position: absolute; - left: 50%; - top: 50%; - width: 10px; - height: 10px; - margin-left: -5px; - margin-top: -5px; - background: #2a6df4; - border: 2px solid #fff; - border-radius: 50%; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); -} - -.user-dot__pulse { - position: absolute; - left: 50%; - top: 50%; - width: 22px; - height: 22px; - margin-left: -11px; - margin-top: -11px; - border-radius: 50%; - background: rgba(42, 109, 244, 0.25); - animation: userPulse 1.8s ease-out infinite; -} - -/* Map attribution */ -.map-attribution { - position: absolute; - left: 8px; - bottom: 8px; - display: flex; - align-items: flex-end; - gap: 0.4rem; - z-index: 2; -} - -.map-attribution__toggle { - width: 28px; - height: 28px; - border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.2); - background: rgba(255, 255, 255, 0.9); - color: #111; - font-weight: 700; - font-size: 0.85rem; - cursor: pointer; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); -} - -[data-theme="dark"] .map-attribution__toggle { - background: rgba(17, 24, 39, 0.9); - color: #f8fafc; - border-color: rgba(255, 255, 255, 0.2); -} - -.map-attribution__panel { - font-size: 0.7rem; - background: rgba(0, 0, 0, 0.75); - color: #fff; - padding: 0.35rem 0.65rem; - border-radius: 999px; - max-width: 220px; - opacity: 0; - transform: translateX(-6px) scale(0.98); - transform-origin: left bottom; - transition: opacity 0.2s ease, transform 0.2s ease; - pointer-events: none; - line-height: 1.2; -} - -.map-attribution__panel a { - color: inherit; - text-decoration: underline; - font-weight: 600; -} - -.map-attribution.open .map-attribution__panel { - opacity: 1; - transform: translateX(0) scale(1); - pointer-events: auto; -} - -@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; - } -} diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx deleted file mode 100644 index b00ca1c..0000000 --- a/src/frontend/app/components/StopMapSheet.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import maplibregl from "maplibre-gl"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; -import { useApp } from "~/AppContext"; -import { REGION_DATA } from "~/config/RegionConfig"; -import { getLineColour } from "~/data/LineColors"; -import type { Stop } from "~/data/StopDataProvider"; -import { loadStyle } from "~/maps/styleloader"; -import "./StopMapSheet.css"; - -export interface Position { - latitude: number; - longitude: number; - orientationDegrees: number; - shapeIndex?: number; -} - -export interface ConsolidatedCirculationForMap { - line: string; - route: string; - currentPosition?: Position; - stopShapeIndex?: number; - schedule?: { - shapeId?: string; - }; -} - -interface StopMapProps { - stop: Stop; - circulations: ConsolidatedCirculationForMap[]; -} - -export const StopMap: React.FC = ({ - stop, - circulations -}) => { - const { theme } = useApp(); - const [styleSpec, setStyleSpec] = useState(null); - const mapRef = useRef(null); - const [userPosition, setUserPosition] = useState<{ - latitude: number; - longitude: number; - accuracy?: number; - } | null>(null); - const geoWatchId = useRef(null); - const [viewState, setViewState] = useState(() => { - let latitude = 42.2406; - let longitude = -8.7207; - if (stop.latitude && stop.longitude) { - latitude = stop.latitude; - longitude = stop.longitude; - } else { - const pos = circulations.find((c) => c.currentPosition)?.currentPosition; - if (pos) { - latitude = pos.latitude; - longitude = pos.longitude; - } - } - return { - latitude, - longitude, - zoom: 16, - }; - }); - const { zoom } = viewState; - const hasFitted = useRef(false); - const [moveTick, setMoveTick] = useState(0); - const [showAttribution, setShowAttribution] = useState(false); - const [shapes, setShapes] = useState>({}); - - useEffect(() => { - circulations.forEach((c) => { - if ( - c.schedule?.shapeId && - c.currentPosition?.shapeIndex !== undefined && - REGION_DATA.shapeEndpoint - ) { - const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; - if (!shapes[key]) { - let url = `${REGION_DATA.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`; - if (c.stopShapeIndex !== undefined) { - url += `&stopShapeIndex=${c.stopShapeIndex}`; - } else { - url += `&stopLat=${stop.latitude}&stopLon=${stop.longitude}`; - } - - fetch(url) - .then((res) => { - if (res.ok) return res.json(); - return null; - }) - .then((data) => { - if (data) { - setShapes((prev) => ({ ...prev, [key]: data })); - } - }) - .catch((err) => console.error("Failed to load shape", err)); - } - } - }); - }, [circulations, shapes]); - - type Pt = { lat: number; lon: number }; - const haversineKm = (a: Pt, b: Pt) => { - const R = 6371; - const dLat = ((b.lat - a.lat) * Math.PI) / 180; - const dLon = ((b.lon - a.lon) * Math.PI) / 180; - const lat1 = (a.lat * Math.PI) / 180; - 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; - 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, - }); - } - 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; - } - anchor = { lat: lat / buses.length, lon: lon / buses.length }; - } - - const nearBuses = buses - .map((p) => ({ p, d: anchor ? haversineKm(anchor, p) : 0 })) - .sort((a, b) => a.d - b.d) - .slice(0, 8) // take closest N - .filter((x) => x.d <= 8) // within 8km - .map((x) => x.p); - - const pts: Pt[] = []; - if (stopPt) pts.push(stopPt); - pts.push(...nearBuses); - if (userPt) { - // include user if not too far from anchor - const includeUser = anchor ? haversineKm(anchor, userPt) <= 10 : true; - if (includeUser) pts.push(userPt); - } - // Fallback: if no buses survived, at least return stop or user - if (pts.length === 0) { - if (stopPt) return [stopPt]; - if (userPt) return [userPt]; - } - return pts; - }; - - useEffect(() => { - let mounted = true; - loadStyle("openfreemap", theme) - .then((style) => { - if (mounted) setStyleSpec(style); - }) - .catch((err) => console.error("Failed to load map style", err)); - return () => { - mounted = false; - }; - }, [theme]); - - // Geolocation: request immediately without blocking UI; update when available. - useEffect(() => { - if (!("geolocation" in navigator)) return; - try { - navigator.geolocation.getCurrentPosition( - (pos) => { - setUserPosition({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - accuracy: pos.coords.accuracy, - }); - }, - () => { }, - { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 } - ); - geoWatchId.current = navigator.geolocation.watchPosition( - (pos) => { - setUserPosition({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - accuracy: pos.coords.accuracy, - }); - }, - () => { }, - { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 } - ); - } catch { } - return () => { - if (geoWatchId.current != null && "geolocation" in navigator) { - try { - navigator.geolocation.clearWatch(geoWatchId.current); - } catch { } - } - }; - }, []); - - const busPositions = useMemo( - () => circulations.filter((c) => !!c.currentPosition), - [circulations] - ); - - const handleMapLoad = (e: any) => { - if (hasFitted.current) return; - hasFitted.current = true; - - const map = e.target; - - // Handle missing sprite images to suppress console warnings - const handleStyleImageMissing = (e: any) => { - if (!map || map.hasImage(e.id)) return; - // Add a transparent 1x1 placeholder - map.addImage(e.id, { - width: 1, - height: 1, - data: new Uint8Array(4), - }); - }; - - map.on("styleimagemissing", handleStyleImageMissing); - - const points = computeFocusPoints(); - if (points.length === 0) return; - - let minLat = points[0].lat, - maxLat = points[0].lat, - minLon = points[0].lon, - maxLon = points[0].lon; - for (const p of points) { - if (p.lat < minLat) minLat = p.lat; - if (p.lat > maxLat) maxLat = p.lat; - if (p.lon < minLon) minLon = p.lon; - if (p.lon > maxLon) maxLon = p.lon; - } - - const sw = [minLon, minLat] as [number, number]; - const ne = [maxLon, maxLat] as [number, number]; - 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; - - // If the diagonal is huge (likely outliers sneaked in), clamp via zoom fallback - try { - if (points.length === 1) { - const only = points[0]; - map.jumpTo({ center: [only.lon, only.lat], zoom: 16 }); - } else { - map.fitBounds(bounds, { - padding: padding as any, - duration: 700, - maxZoom: 17, - } as any); - } - } catch { } - }; - - const handleCenter = () => { - if (!mapRef.current) return; - 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; - for (const p of pts) { - if (p.lat < minLat) minLat = p.lat; - if (p.lat > maxLat) maxLat = p.lat; - if (p.lon < minLon) minLon = p.lon; - if (p.lon > maxLon) maxLon = p.lon; - } - - const sw = [minLon, minLat] as [number, number]; - 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; - - try { - if (pts.length === 1) { - const only = pts[0]; - 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); - } - } catch { } - }; - - return ( -
- {styleSpec && ( - { - setViewState(e.viewState); - setMoveTick((t) => (t + 1) % 1000000); - }} - > - {/* Shapes */} - {circulations.map((c, idx) => { - if ( - !c.schedule?.shapeId || - c.currentPosition?.shapeIndex === undefined - ) - return null; - const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; - const shapeData = shapes[key]; - if (!shapeData) return null; - const lineColor = getLineColour(c.line); - - return ( - - - - - ); - })} - - {/* Stop marker (center) */} - {stop.latitude && stop.longitude && ( - -
- - - - - - - - - - -
-
- )} - - {/* User position marker (if available) */} - {userPosition && ( - -
-
-
-
- - )} - - {/* Bus markers with heading and dynamic label spacing */} - {(() => { - const map = mapRef.current?.getMap(); - const baseGap = 6; - const thresholdPx = 22; - 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 - ); - for (let i = 0; i < pts.length; i++) { - const pi = pts[i]; - if (!pi) continue; - let close = 0; - for (let j = 0; j < pts.length; j++) { - if (i === j) continue; - const pj = pts[j]; - if (!pj) continue; - const dx = pi.x - pj.x; - const dy = pi.y - pj.y; - if (dx * dx + dy * dy <= thresholdPx * thresholdPx) close++; - } - gaps[i] = baseGap + Math.min(3, close) * 10; - } - } - - return busPositions.map((c, idx) => { - const p = c.currentPosition!; - const lineColor = getLineColour(c.line); - const showLabel = zoom >= 13; - const labelGap = gaps[idx] ?? baseGap; - return ( - -
- - - - {showLabel && ( -
- {c.line} -
- )} -
-
- ); - }); - })()} - - )} - -
- -
- -
- -
- OpenFreeMap © OpenMapTiles data from - - OpenStreetMap - -
-
-
- ); -}; diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css deleted file mode 100644 index 5869d41..0000000 --- a/src/frontend/app/components/StopSheet.css +++ /dev/null @@ -1,309 +0,0 @@ -/* Stop Sheet Styles */ -.react-modal-sheet-container { - background-color: var(--background-color) !important; - touch-action: none; -} - -/*.react-modal-sheet-content > * > *:not(.stop-sheet-actions){ - interactivity: inert; -}*/ - -.react-modal-sheet-content-scroller { - overscroll-behavior-y: unset !important; - overflow-y: unset !important; -} - -.stop-sheet-content { - padding: 16px; - display: flex; - flex-direction: column; - /* overflow: hidden; */ - touch-action: pan-y; -} - -.stop-sheet-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; -} - -.stop-sheet-title { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-color); - margin: 0; -} - -.stop-sheet-id { - font-size: 1rem; - color: var(--subtitle-color); -} - -.stop-sheet-lines-container { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.stop-sheet-lines-container.scrollable { - display: grid; - grid-template-rows: repeat(2, 1fr); - grid-auto-flow: column; - /* align-content: flex-start; */ - scrollbar-width: thin; - gap: 0.5rem 1rem; - overflow-x: scroll; -} - -.stop-sheet-lines-container.scrollable::-webkit-scrollbar { - height: 6px; -} - -.stop-sheet-lines-container.scrollable::-webkit-scrollbar-thumb { - background-color: var(--border-color); - border-radius: 3px; -} - -.stop-sheet-line-icon { - flex-shrink: 0; -} - -.stop-sheet-loading { - display: flex; - justify-content: center; - align-items: center; - padding: 32px; - color: var(--subtitle-color); - font-size: 1rem; -} - -.stop-sheet-estimates { - flex: 1; - min-height: 0; - margin-block-start: 1.25rem; -} - -.stop-sheet-subtitle { - font-size: 1.1rem; - font-weight: 500; - color: var(--text-color); - margin: 0 0 12px 0; -} - -.stop-sheet-no-estimates { - text-align: center; - padding: 32px 16px; - color: var(--subtitle-color); - font-size: 0.95rem; -} - -.stop-sheet-estimates-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.stop-sheet-estimate-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background-color: var(--message-background-color); - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.stop-sheet-estimate-line { - flex-shrink: 0; -} - -.stop-sheet-estimate-details { - flex: 1; - min-width: 0; -} - -.stop-sheet-estimate-route { - font-weight: 500; - color: var(--text-color); - font-size: 0.95rem; - margin-bottom: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stop-sheet-estimate-arrival { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - flex-shrink: 0; -} - -.stop-sheet-estimate-time { - display: flex; - align-items: center; - gap: 6px; - font-size: 1.05rem; - font-weight: 600; - color: var(--text-color); -} - -.stop-sheet-estimate-time.is-minutes { - color: #22c55e; -} - -.stop-sheet-estimate-time svg { - width: 18px; - height: 18px; - color: var(--subtitle-color); - flex-shrink: 0; -} - -.stop-sheet-estimate-time.is-minutes svg { - color: #22c55e; -} - -.stop-sheet-estimate-distance { - font-size: 0.75rem; - color: var(--subtitle-color); - text-align: right; -} - -.stop-sheet-footer { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin: 0; - padding: 0.75rem 16px 1rem 16px; - border-top: 1px solid var(--border-color); - background-color: var(--background-color); - z-index: 10; -} - -.stop-sheet-timestamp { - font-size: 0.8rem; - color: var(--subtitle-color); - text-align: center; -} - -.stop-sheet-actions { - display: flex; - gap: 0.75rem; -} - -.stop-sheet-reload { - display: inline-flex; - align-items: center; - gap: 0.4rem; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-color); - padding: 0.5rem 0.75rem; - border-radius: 6px; - font-size: 0.85rem; - cursor: pointer; - transition: all 0.2s ease; - flex: 1; - justify-content: center; -} - -.stop-sheet-reload:hover:not(:disabled) { - background: var(--message-background-color); - border-color: var(--button-background-color); -} - -.stop-sheet-reload:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.reload-icon { - width: 14px; - height: 14px; - transition: transform 0.5s ease; -} - -.reload-icon.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.stop-sheet-view-all { - display: block; - padding: 0.5rem 0.75rem; - background-color: var(--button-background-color); - color: white; - text-decoration: none; - text-align: center; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: background-color 0.2s ease; - flex: 2; -} - -.stop-sheet-view-all:hover { - background-color: var(--button-hover-background-color); - text-decoration: none; -} - -/* Error display adjustments for sheet */ -.stop-sheet-content .error-display { - margin: 1rem 0; -} - -.stop-sheet-content .error-display.compact { - min-height: 100px; - padding: 1rem; -} - -.stop-sheet-content .error-display.compact .error-icon { - width: 28px; - height: 28px; -} - -.stop-sheet-content .error-display.compact .error-title { - font-size: 1.1rem; -} - -.stop-sheet-content .error-display.compact .error-message { - font-size: 0.85rem; -} - -[data-rsbs-overlay] { - background-color: rgba(0, 0, 0, 0.3); -} - -[data-rsbs-header] { - background-color: var(--background-color); - border-bottom: 1px solid var(--border-color); - touch-action: none; -} - -[data-rsbs-header]:before { - background-color: var(--subtitle-color); -} - -[data-rsbs-root] [data-rsbs-overlay] { - border-top-left-radius: 16px; - border-top-right-radius: 16px; -} - -[data-rsbs-root] [data-rsbs-content] { - background-color: var(--background-color); - border-top-left-radius: 16px; - border-top-right-radius: 16px; - max-height: 95vh; - overflow: hidden; - touch-action: none; -} diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx deleted file mode 100644 index 77bb5f1..0000000 --- a/src/frontend/app/components/StopSheet.tsx +++ /dev/null @@ -1,213 +0,0 @@ -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 { REGION_DATA } from "~/config/RegionConfig"; -import type { Stop } from "~/data/StopDataProvider"; -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"; - -interface StopSheetProps { - isOpen: boolean; - onClose: () => void; - stop: Stop; -} - -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadConsolidatedData = async ( - stopId: number -): Promise => { - const resp = await fetch( - `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - -export const StopSheet: React.FC = ({ - isOpen, - onClose, - stop, -}) => { - const { t } = useTranslation(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - - const parseError = (error: any): ErrorInfo => { - if (!navigator.onLine) { - return { type: "network", message: "No internet connection" }; - } - - if ( - error.message?.includes("Failed to fetch") || - error.message?.includes("NetworkError") - ) { - return { type: "network" }; - } - - if (error.message?.includes("HTTP")) { - const statusMatch = error.message.match(/HTTP (\d+):/); - const status = statusMatch ? parseInt(statusMatch[1]) : undefined; - return { type: "server", status }; - } - - return { type: "unknown", message: error.message }; - }; - - const loadData = async () => { - try { - setLoading(true); - setError(null); - setData(null); - - const stopData = await loadConsolidatedData(stop.stopId); - setData(stopData); - setLastUpdated(new Date()); - } catch (err) { - console.error("Failed to load stop data:", err); - setError(parseError(err)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (isOpen && stop.stopId) { - loadData(); - } - }, [isOpen, stop.stopId]); - - // Show only the next 4 arrivals - 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 ( - - - - -
-
-

{stop.name.original}

- ({stop.stopId}) -
- -
= 10 ? "scrollable" : ""}`} - > - {stop.lines.map((line) => ( -
- -
- ))} -
- - - - {loading ? ( - - ) : error ? ( - - ) : data ? ( - <> -
-

- {t("estimates.next_arrivals", "Next arrivals")} -

- - {limitedEstimates.length === 0 ? ( -
- {t("estimates.none", "No hay estimaciones disponibles")} -
- ) : ( -
- {limitedEstimates.map((estimate, idx) => ( - - ))} -
- )} -
- - ) : null} -
-
- -
- {lastUpdated && ( -
- {t("estimates.last_updated", "Actualizado a las")}{" "} - {lastUpdated.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} -
- )} - -
- - - - {t("map.view_all_estimates", "Ver todas las estimaciones")} - -
-
-
- -
- ); -}; diff --git a/src/frontend/app/components/StopSheetSkeleton.tsx b/src/frontend/app/components/StopSheetSkeleton.tsx deleted file mode 100644 index 3874038..0000000 --- a/src/frontend/app/components/StopSheetSkeleton.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; -import { useTranslation } from "react-i18next"; - -interface StopSheetSkeletonProps { - rows?: number; -} - -export const StopSheetSkeleton: React.FC = ({ - rows = 4, -}) => { - const { t } = useTranslation(); - - return ( - -
-

- {t("estimates.next_arrivals", "Next arrivals")} -

- -
- {Array.from({ length: rows }, (_, index) => ( -
-
- -
- -
-
- -
-
- -
-
-
- ))} -
-
- -
-
- -
- -
-
- -
- -
- -
-
-
-
- ); -}; diff --git a/src/frontend/app/components/StopSummarySheet.css b/src/frontend/app/components/StopSummarySheet.css new file mode 100644 index 0000000..5869d41 --- /dev/null +++ b/src/frontend/app/components/StopSummarySheet.css @@ -0,0 +1,309 @@ +/* Stop Sheet Styles */ +.react-modal-sheet-container { + background-color: var(--background-color) !important; + touch-action: none; +} + +/*.react-modal-sheet-content > * > *:not(.stop-sheet-actions){ + interactivity: inert; +}*/ + +.react-modal-sheet-content-scroller { + overscroll-behavior-y: unset !important; + overflow-y: unset !important; +} + +.stop-sheet-content { + padding: 16px; + display: flex; + flex-direction: column; + /* overflow: hidden; */ + touch-action: pan-y; +} + +.stop-sheet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.stop-sheet-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-color); + margin: 0; +} + +.stop-sheet-id { + font-size: 1rem; + color: var(--subtitle-color); +} + +.stop-sheet-lines-container { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.stop-sheet-lines-container.scrollable { + display: grid; + grid-template-rows: repeat(2, 1fr); + grid-auto-flow: column; + /* align-content: flex-start; */ + scrollbar-width: thin; + gap: 0.5rem 1rem; + overflow-x: scroll; +} + +.stop-sheet-lines-container.scrollable::-webkit-scrollbar { + height: 6px; +} + +.stop-sheet-lines-container.scrollable::-webkit-scrollbar-thumb { + background-color: var(--border-color); + border-radius: 3px; +} + +.stop-sheet-line-icon { + flex-shrink: 0; +} + +.stop-sheet-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 32px; + color: var(--subtitle-color); + font-size: 1rem; +} + +.stop-sheet-estimates { + flex: 1; + min-height: 0; + margin-block-start: 1.25rem; +} + +.stop-sheet-subtitle { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.stop-sheet-no-estimates { + text-align: center; + padding: 32px 16px; + color: var(--subtitle-color); + font-size: 0.95rem; +} + +.stop-sheet-estimates-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stop-sheet-estimate-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: var(--message-background-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.stop-sheet-estimate-line { + flex-shrink: 0; +} + +.stop-sheet-estimate-details { + flex: 1; + min-width: 0; +} + +.stop-sheet-estimate-route { + font-weight: 500; + color: var(--text-color); + font-size: 0.95rem; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stop-sheet-estimate-arrival { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + +.stop-sheet-estimate-time { + display: flex; + align-items: center; + gap: 6px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-color); +} + +.stop-sheet-estimate-time.is-minutes { + color: #22c55e; +} + +.stop-sheet-estimate-time svg { + width: 18px; + height: 18px; + color: var(--subtitle-color); + flex-shrink: 0; +} + +.stop-sheet-estimate-time.is-minutes svg { + color: #22c55e; +} + +.stop-sheet-estimate-distance { + font-size: 0.75rem; + color: var(--subtitle-color); + text-align: right; +} + +.stop-sheet-footer { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 0.75rem 16px 1rem 16px; + border-top: 1px solid var(--border-color); + background-color: var(--background-color); + z-index: 10; +} + +.stop-sheet-timestamp { + font-size: 0.8rem; + color: var(--subtitle-color); + text-align: center; +} + +.stop-sheet-actions { + display: flex; + gap: 0.75rem; +} + +.stop-sheet-reload { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + justify-content: center; +} + +.stop-sheet-reload:hover:not(:disabled) { + background: var(--message-background-color); + border-color: var(--button-background-color); +} + +.stop-sheet-reload:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reload-icon { + width: 14px; + height: 14px; + transition: transform 0.5s ease; +} + +.reload-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.stop-sheet-view-all { + display: block; + padding: 0.5rem 0.75rem; + background-color: var(--button-background-color); + color: white; + text-decoration: none; + text-align: center; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: background-color 0.2s ease; + flex: 2; +} + +.stop-sheet-view-all:hover { + background-color: var(--button-hover-background-color); + text-decoration: none; +} + +/* Error display adjustments for sheet */ +.stop-sheet-content .error-display { + margin: 1rem 0; +} + +.stop-sheet-content .error-display.compact { + min-height: 100px; + padding: 1rem; +} + +.stop-sheet-content .error-display.compact .error-icon { + width: 28px; + height: 28px; +} + +.stop-sheet-content .error-display.compact .error-title { + font-size: 1.1rem; +} + +.stop-sheet-content .error-display.compact .error-message { + font-size: 0.85rem; +} + +[data-rsbs-overlay] { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-rsbs-header] { + background-color: var(--background-color); + border-bottom: 1px solid var(--border-color); + touch-action: none; +} + +[data-rsbs-header]:before { + background-color: var(--subtitle-color); +} + +[data-rsbs-root] [data-rsbs-overlay] { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +[data-rsbs-root] [data-rsbs-content] { + background-color: var(--background-color); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + max-height: 95vh; + overflow: hidden; + touch-action: none; +} diff --git a/src/frontend/app/components/StopSummarySheet.tsx b/src/frontend/app/components/StopSummarySheet.tsx new file mode 100644 index 0000000..17c0afd --- /dev/null +++ b/src/frontend/app/components/StopSummarySheet.tsx @@ -0,0 +1,208 @@ +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 { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; +import { REGION_DATA } from "~/config/RegionConfig"; +import type { Stop } from "~/data/StopDataProvider"; +import { type ConsolidatedCirculation } from "../routes/stops-$id"; +import { ErrorDisplay } from "./ErrorDisplay"; +import LineIcon from "./LineIcon"; +import { StopAlert } from "./StopAlert"; +import "./StopSummarySheet.css"; +import { StopSummarySheetSkeleton } from "./StopSummarySheetSkeleton"; + +interface StopSheetProps { + isOpen: boolean; + onClose: () => void; + stop: Stop; +} + +interface ErrorInfo { + type: "network" | "server" | "unknown"; + status?: number; + message?: string; +} + +const loadConsolidatedData = async ( + stopId: number +): Promise => { + const resp = await fetch( + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + return await resp.json(); +}; + +export const StopSheet: React.FC = ({ + isOpen, + onClose, + stop, +}) => { + const { t } = useTranslation(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + const parseError = (error: any): ErrorInfo => { + if (!navigator.onLine) { + return { type: "network", message: "No internet connection" }; + } + + if ( + error.message?.includes("Failed to fetch") || + error.message?.includes("NetworkError") + ) { + return { type: "network" }; + } + + if (error.message?.includes("HTTP")) { + const statusMatch = error.message.match(/HTTP (\d+):/); + const status = statusMatch ? parseInt(statusMatch[1]) : undefined; + return { type: "server", status }; + } + + return { type: "unknown", message: error.message }; + }; + + const loadData = async () => { + try { + setLoading(true); + setError(null); + setData(null); + + const stopData = await loadConsolidatedData(stop.stopId); + setData(stopData); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load stop data:", err); + setError(parseError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen && stop.stopId) { + loadData(); + } + }, [isOpen, stop.stopId]); + + // Show only the next 4 arrivals + 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 ( + + + + +
+
+

{stop.name.original}

+ ({stop.stopId}) +
+ +
= 10 ? "scrollable" : ""}`} + > + {stop.lines.map((line) => ( +
+ +
+ ))} +
+ + + + {loading ? ( + + ) : error ? ( + + ) : data ? ( + <> +
+

+ {t("estimates.next_arrivals", "Next arrivals")} +

+ + {limitedEstimates.length === 0 ? ( +
+ {t("estimates.none", "No hay estimaciones disponibles")} +
+ ) : ( + + )} +
+ + ) : null} +
+
+ +
+ {lastUpdated && ( +
+ {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} +
+ )} + +
+ + + + {t("map.view_all_estimates", "Ver todas las estimaciones")} + +
+
+
+ +
+ ); +}; diff --git a/src/frontend/app/components/StopSummarySheetSkeleton.tsx b/src/frontend/app/components/StopSummarySheetSkeleton.tsx new file mode 100644 index 0000000..7697efc --- /dev/null +++ b/src/frontend/app/components/StopSummarySheetSkeleton.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; + +interface StopSheetSkeletonProps { + rows?: number; +} + +export const StopSummarySheetSkeleton: React.FC = ({ + rows = 4, +}) => { + const { t } = useTranslation(); + + return ( + +
+

+ {t("estimates.next_arrivals", "Next arrivals")} +

+ +
+ {Array.from({ length: rows }, (_, index) => ( +
+
+ +
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ ); +}; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 7198c7b..8f43939 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -10,6 +10,7 @@ interface ConsolidatedCirculationCardProps { estimate: ConsolidatedCirculation; onMapClick?: () => void; readonly?: boolean; + reduced?: boolean; } // Utility function to parse service ID and get the turn number @@ -71,7 +72,7 @@ const parseServiceId = (serviceId: string): string => { export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, onMapClick, readonly }) => { +> = ({ estimate, onMapClick, readonly, reduced }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -118,7 +119,7 @@ export const ConsolidatedCirculationCard: React.FC< // On time if (delta === 0) { return { - label: t("estimates.delay_on_time", "En hora (0 min)"), + label: reduced ? "OK" : t("estimates.delay_on_time", "En hora (0 min)"), tone: "delay-ok", } as const; } @@ -128,7 +129,7 @@ export const ConsolidatedCirculationCard: React.FC< const tone = delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical"; return { - label: t("estimates.delay_positive", "Retraso de {{minutes}} min", { + label: reduced ? `R${delta}` : t("estimates.delay_positive", "Retraso de {{minutes}} min", { minutes: delta, }), tone, @@ -138,12 +139,12 @@ export const ConsolidatedCirculationCard: React.FC< // Early const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; return { - label: t("estimates.delay_negative", "Adelanto de {{minutes}} min", { + label: reduced ? `A${absDelta}` : t("estimates.delay_negative", "Adelanto de {{minutes}} min", { minutes: absDelta, }), tone, } as const; - }, [estimate.schedule, estimate.realTime, t]); + }, [estimate.schedule, estimate.realTime, t, reduced]); const metaChips = useMemo(() => { const chips: Array<{ label: string; tone?: string }> = []; @@ -175,6 +176,84 @@ export const ConsolidatedCirculationCard: React.FC< disabled: !hasGpsPosition, }; + if (reduced) { + return ( + +
+ +
+
+ + {estimate.route} + + {metaChips.length > 0 && ( +
+ {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300"; + break; + default: + chipColourClasses = "bg-black/[0.06] dark:bg-white/[0.12] text-[var(--text-color)]"; + } + + return ( + + {chip.label} + + ); + })} +
+ )} +
+
+
+ {etaValue} + {etaUnit} +
+
+
+ ); + } + return ( -
-
- -
-
- {estimate.route} -
- {hasGpsPosition && ( -
- + <> +
+
+
- )} -
-
- {etaValue} - {etaUnit} +
+ {estimate.route} + {estimate.nextStreets && estimate.nextStreets.length > 0 && ( + +
+ {estimate.nextStreets.join(" — ")} +
+
+ )}
-
-
- {metaChips.length > 0 && ( -
- {metaChips.map((chip, idx) => ( - - {chip.label} - - ))} - - {estimate.nextStreets && estimate.nextStreets.length > 0 && ( - -
- {estimate.nextStreets.join(" — ")} -
+ {hasGpsPosition && ( +
+ +
)} +
+
+ {etaValue} + {etaUnit} +
+
- )} + + {metaChips.length > 0 && ( +
+ {metaChips.map((chip, idx) => ( + + {chip.label} + + ))} +
+ )} + ); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 547fdf7..088f978 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -2,18 +2,19 @@ import { useTranslation } from "react-i18next"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard"; +import { useCallback } from "react"; import "./ConsolidatedCirculationList.css"; -interface RegularTableProps { +interface ConsolidatedCirculationListProps { data: ConsolidatedCirculation[]; - dataDate: Date | null; onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; + reduced?: boolean; } -export const ConsolidatedCirculationList: React.FC = ({ +export const ConsolidatedCirculationList: React.FC = ({ data, - dataDate, onCirculationClick, + reduced, }) => { const { t } = useTranslation(); @@ -23,28 +24,31 @@ export const ConsolidatedCirculationList: React.FC = ({ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) ); + const generateKey = useCallback((estimate: ConsolidatedCirculation) => { + if (estimate.realTime && estimate.schedule) { + return `rt-${estimate.schedule.tripId}`; + } + + return `sch-${estimate.schedule ? estimate.schedule.tripId : estimate.line + "-" + estimate.route}`; + }, []); + return ( <> -
- {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { - time: dataDate?.toLocaleTimeString(), - })} -
- {sortedData.length === 0 ? (
{t("estimates.none", "No hay estimaciones disponibles")}
) : ( - <> +
{sortedData.map((estimate, idx) => ( onCirculationClick?.(estimate, idx)} /> ))} - +
)} ); -- cgit v1.3