aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/api/arrivals.ts31
-rw-r--r--src/frontend/app/api/schema.ts96
-rw-r--r--src/frontend/app/components/Stops/ArrivalCard.css17
-rw-r--r--src/frontend/app/components/Stops/ArrivalCard.tsx72
-rw-r--r--src/frontend/app/components/Stops/ArrivalList.tsx25
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx109
-rw-r--r--src/frontend/app/hooks/useArrivals.ts16
-rw-r--r--src/frontend/app/root.tsx11
-rw-r--r--src/frontend/app/routes/map.tsx1
-rw-r--r--src/frontend/package-lock.json37
-rw-r--r--src/frontend/package.json4
11 files changed, 318 insertions, 101 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,
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index 5c12580..6c8284b 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
"@tailwindcss/vite": "^4.1.17",
+ "@tanstack/react-query": "^5.90.12",
"framer-motion": "^12.23.24",
"fuse.js": "^7.1.0",
"i18next-browser-languagedetector": "^8.2.0",
@@ -27,7 +28,8 @@
"react-loading-skeleton": "^3.5.0",
"react-modal-sheet": "^5.2.1",
"react-router": "^7.9.6",
- "tailwindcss": "^4.1.17"
+ "tailwindcss": "^4.1.17",
+ "zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2156,6 +2158,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
+ "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
+ "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -7298,10 +7326,9 @@
}
},
"node_modules/zod": {
- "version": "4.1.12",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
- "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
- "dev": true,
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
+ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 7734ae2..bd55dff 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -18,6 +18,7 @@
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
"@tailwindcss/vite": "^4.1.17",
+ "@tanstack/react-query": "^5.90.12",
"framer-motion": "^12.23.24",
"fuse.js": "^7.1.0",
"i18next-browser-languagedetector": "^8.2.0",
@@ -33,7 +34,8 @@
"react-loading-skeleton": "^3.5.0",
"react-modal-sheet": "^5.2.1",
"react-router": "^7.9.6",
- "tailwindcss": "^4.1.17"
+ "tailwindcss": "^4.1.17",
+ "zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",