diff options
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/api/arrivals.ts | 31 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 96 | ||||
| -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 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useArrivals.ts | 16 | ||||
| -rw-r--r-- | src/frontend/app/root.tsx | 11 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 1 |
9 files changed, 283 insertions, 95 deletions
diff --git a/src/frontend/app/api/arrivals.ts b/src/frontend/app/api/arrivals.ts new file mode 100644 index 0000000..8ae6e78 --- /dev/null +++ b/src/frontend/app/api/arrivals.ts @@ -0,0 +1,31 @@ +import { + StopArrivalsResponseSchema, + type StopArrivalsResponse, +} from "./schema"; + +export const fetchArrivals = async ( + stopId: string, + reduced: boolean = false +): Promise<StopArrivalsResponse> => { + const resp = await fetch( + `/api/stops/arrivals?id=${stopId}&reduced=${reduced}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + try { + return StopArrivalsResponseSchema.parse(data); + } catch (e) { + console.error("Zod parsing failed for arrivals:", e); + console.log("Received data:", data); + throw e; + } +}; diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts new file mode 100644 index 0000000..60e2d97 --- /dev/null +++ b/src/frontend/app/api/schema.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +export const RouteInfoSchema = z.object({ + shortName: z.string(), + colour: z.string(), + textColour: z.string(), +}); + +export const HeadsignInfoSchema = z.object({ + badge: z.string().optional().nullable(), + destination: z.string(), + marquee: z.string().optional().nullable(), +}); + +export const ArrivalPrecissionSchema = z.enum([ + "confident", + "unsure", + "scheduled", + "past", +]); + +export const ArrivalDetailsSchema = z.object({ + minutes: z.number(), + precission: ArrivalPrecissionSchema, +}); + +export const DelayBadgeSchema = z.object({ + minutes: z.number(), +}); + +export const ShiftBadgeSchema = z.object({ + shiftName: z.string(), + shiftTrip: z.string(), +}); + +export const ArrivalSchema = z.object({ + route: RouteInfoSchema, + headsign: HeadsignInfoSchema, + estimate: ArrivalDetailsSchema, + delay: DelayBadgeSchema.optional().nullable(), + shift: ShiftBadgeSchema.optional().nullable(), +}); + +export const StopArrivalsResponseSchema = z.object({ + stopCode: z.string(), + stopName: z.string(), + arrivals: z.array(ArrivalSchema), +}); + +export type RouteInfo = z.infer<typeof RouteInfoSchema>; +export type HeadsignInfo = z.infer<typeof HeadsignInfoSchema>; +export type ArrivalPrecission = z.infer<typeof ArrivalPrecissionSchema>; +export type ArrivalDetails = z.infer<typeof ArrivalDetailsSchema>; +export type DelayBadge = z.infer<typeof DelayBadgeSchema>; +export type ShiftBadge = z.infer<typeof ShiftBadgeSchema>; +export type Arrival = z.infer<typeof ArrivalSchema>; +export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>; + +// Consolidated Circulation (Legacy/Alternative API) +export const ConsolidatedCirculationSchema = z.object({ + line: z.string(), + route: z.string(), + schedule: z + .object({ + running: z.boolean(), + minutes: z.number(), + serviceId: z.string(), + tripId: z.string(), + shapeId: z.string().optional().nullable(), + }) + .optional() + .nullable(), + realTime: z + .object({ + minutes: z.number(), + distance: z.number(), + }) + .optional() + .nullable(), + currentPosition: z + .object({ + latitude: z.number(), + longitude: z.number(), + orientationDegrees: z.number(), + shapeIndex: z.number().optional().nullable(), + }) + .optional() + .nullable(), + isPreviousTrip: z.boolean().optional().nullable(), + previousTripShapeId: z.string().optional().nullable(), + nextStreets: z.array(z.string()).optional().nullable(), +}); + +export type ConsolidatedCirculation = z.infer< + typeof ConsolidatedCirculationSchema +>; 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> </> diff --git a/src/frontend/app/hooks/useArrivals.ts b/src/frontend/app/hooks/useArrivals.ts new file mode 100644 index 0000000..4b0d331 --- /dev/null +++ b/src/frontend/app/hooks/useArrivals.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchArrivals } from "../api/arrivals"; + +export const useStopArrivals = ( + stopId: string, + reduced: boolean = false, + enabled: boolean = true +) => { + return useQuery({ + queryKey: ["arrivals", stopId, reduced], + queryFn: () => fetchArrivals(stopId, reduced), + enabled: !!stopId && enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: false, // Disable retries to see errors immediately + }); +}; diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 49c9dc8..1354660 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -20,8 +20,11 @@ const pmtiles = new Protocol(); maplibregl.addProtocol("pmtiles", pmtiles.tile); //#endregion +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "./i18n"; +const queryClient = new QueryClient(); + export const links: Route.LinksFunction = () => []; export function Layout({ children }: { children: React.ReactNode }) { @@ -89,9 +92,11 @@ export default function App() { } return ( - <AppProvider> - <AppShell /> - </AppProvider> + <QueryClientProvider client={queryClient}> + <AppProvider> + <AppShell /> + </AppProvider> + </QueryClientProvider> ); } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 279f096..1ce9942 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -146,7 +146,6 @@ export default function StopMap() { stopCode: props.code, name: props.name || "Unknown Stop", lines: routes.map((route) => { - console.log(route); return { line: route.shortName, colour: route.colour, |
