aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-22 18:16:57 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-22 18:16:57 +0100
commit4b7eaa318f22d7cc768491c421cb7aeac477f95d (patch)
tree0b39abce444679396475e4f48885479e2ae0650f /src/frontend/app/components
parent91f7d7dd5a4ca8453cfdbc9a3beeb216b6638ef7 (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.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
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>
</>