diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-22 18:16:57 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-22 18:16:57 +0100 |
| commit | 4b7eaa318f22d7cc768491c421cb7aeac477f95d (patch) | |
| tree | 0b39abce444679396475e4f48885479e2ae0650f /src/frontend/app/components | |
| parent | 91f7d7dd5a4ca8453cfdbc9a3beeb216b6638ef7 (diff) | |
Implement retrieving next arrivals for a stop (scheduled only)
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/Stops/ArrivalCard.css | 17 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ArrivalCard.tsx | 72 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ArrivalList.tsx | 25 | ||||
| -rw-r--r-- | src/frontend/app/components/map/StopSummarySheet.tsx | 109 |
4 files changed, 132 insertions, 91 deletions
diff --git a/src/frontend/app/components/Stops/ArrivalCard.css b/src/frontend/app/components/Stops/ArrivalCard.css new file mode 100644 index 0000000..5835352 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalCard.css @@ -0,0 +1,17 @@ +@import "../../tailwind.css"; + +.time-running { + @apply bg-green-600/20 dark:bg-green-600/25 text-[#1a9e56] dark:text-[#22c55e]; +} + +.time-delayed { + @apply bg-orange-600/20 dark:bg-orange-600/25 text-[#d06100] dark:text-[#fb923c]; +} + +.time-past { + @apply bg-gray-600/20 dark:bg-gray-600/25 text-gray-600 dark:text-gray-400; +} + +.time-scheduled { + @apply bg-blue-900/20 dark:bg-blue-600/25 text-[#0b3d91] dark:text-[#93c5fd]; +} diff --git a/src/frontend/app/components/Stops/ArrivalCard.tsx b/src/frontend/app/components/Stops/ArrivalCard.tsx new file mode 100644 index 0000000..96d0af0 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalCard.tsx @@ -0,0 +1,72 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import LineIcon from "~/components/LineIcon"; +import { type Arrival } from "../../api/schema"; +import "./ArrivalCard.css"; + +interface ArrivalCardProps { + arrival: Arrival; + reduced?: boolean; +} + +export const ArrivalCard: React.FC<ArrivalCardProps> = ({ + arrival, + reduced, +}) => { + const { t } = useTranslation(); + const { route, headsign, estimate } = arrival; + + const etaValue = Math.max(0, Math.round(estimate.minutes)).toString(); + const etaUnit = t("estimates.minutes", "min"); + + const timeClass = useMemo(() => { + switch (estimate.precission) { + case "confident": + return "time-running"; + case "unsure": + return "time-delayed"; + case "past": + return "time-past"; + default: + return "time-scheduled"; + } + }, [estimate.precission]); + + return ( + <div + className={` + flex-none flex items-center gap-2.5 min-h-12 + bg-(--message-background-color) border border-(--border-color) + rounded-xl px-3 py-2.5 transition-all + ${reduced ? "reduced" : ""} + `.trim()} + > + <div className="shrink-0 min-w-[7ch]"> + <LineIcon + line={route.shortName} + colour={route.colour} + textColour={route.textColour} + mode="pill" + /> + </div> + <div className="flex-1 min-w-0 flex flex-col gap-1"> + <strong className="text-base text-(--text-color) overflow-hidden text-ellipsis line-clamp-2 leading-tight"> + {headsign.destination} + </strong> + </div> + <div + className={` + inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0 + ${timeClass} + `.trim()} + > + <div className="flex flex-col items-center leading-none"> + <span className="text-lg font-bold leading-none">{etaValue}</span> + <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90"> + {etaUnit} + </span> + </div> + </div> + </div> + ); +}; diff --git a/src/frontend/app/components/Stops/ArrivalList.tsx b/src/frontend/app/components/Stops/ArrivalList.tsx new file mode 100644 index 0000000..a1210d5 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalList.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { type Arrival } from "../../api/schema"; +import { ArrivalCard } from "./ArrivalCard"; + +interface ArrivalListProps { + arrivals: Arrival[]; + reduced?: boolean; +} + +export const ArrivalList: React.FC<ArrivalListProps> = ({ + arrivals, + reduced, +}) => { + return ( + <div className="flex flex-col gap-3"> + {arrivals.map((arrival, index) => ( + <ArrivalCard + key={`${arrival.route.shortName}-${index}`} + arrival={arrival} + reduced={reduced} + /> + ))} + </div> + ); +}; diff --git a/src/frontend/app/components/map/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx index b24e71c..16a9cbe 100644 --- a/src/frontend/app/components/map/StopSummarySheet.tsx +++ b/src/frontend/app/components/map/StopSummarySheet.tsx @@ -1,11 +1,10 @@ import { RefreshCw } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React 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 { APP_CONSTANTS } from "~/config/constants"; -import { type ConsolidatedCirculation } from "../../routes/stops-$id"; +import { ArrivalList } from "~/components/Stops/ArrivalList"; +import { useStopArrivals } from "../../hooks/useArrivals"; import { ErrorDisplay } from "../ErrorDisplay"; import LineIcon from "../LineIcon"; import "./StopSummarySheet.css"; @@ -27,95 +26,24 @@ export interface StopSheetProps { }; } -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadConsolidatedData = async ( - stopId: string -): Promise<ConsolidatedCirculation[]> => { - const resp = await fetch( - `${APP_CONSTANTS.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<StopSheetProps> = ({ isOpen, onClose, stop, }) => { const { t } = useTranslation(); - 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); - - 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); - } - }; + const { + data, + isLoading: loading, + error, + refetch: loadData, + dataUpdatedAt, + } = useStopArrivals(stop.stopId, true, isOpen); - useEffect(() => { - if (isOpen && stop.stopId) { - loadData(); - } - }, [isOpen, stop.stopId]); + const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt) : null; // 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); + const limitedEstimates = data?.arrivals.slice(0, 4) ?? []; return ( <Sheet isOpen={isOpen} onClose={onClose} detent="content"> @@ -147,8 +75,11 @@ export const StopSheet: React.FC<StopSheetProps> = ({ <StopSummarySheetSkeleton /> ) : error ? ( <ErrorDisplay - error={error} - onRetry={loadData} + error={{ + type: error.message.includes("HTTP") ? "server" : "network", + message: error.message, + }} + onRetry={() => loadData()} title={t( "errors.estimates_title", "Error al cargar estimaciones" @@ -167,11 +98,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({ {t("estimates.none", "No hay estimaciones disponibles")} </div> ) : ( - <ConsolidatedCirculationList - data={data.slice(0, 4)} - driver={stop.stopFeed} - reduced - /> + <ArrivalList arrivals={limitedEstimates} reduced /> )} </div> </> |
