aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/api/arrivals.ts30
-rw-r--r--src/frontend/app/api/schema.ts14
-rw-r--r--src/frontend/app/components/stop/StopMapModal.tsx5
-rw-r--r--src/frontend/app/data/PlannerApi.ts2
-rw-r--r--src/frontend/app/hooks/useArrivals.ts21
-rw-r--r--src/frontend/app/routes/planner.tsx122
-rw-r--r--src/frontend/app/routes/routes-$id.tsx187
-rw-r--r--src/frontend/app/routes/stops-$id.tsx1
8 files changed, 218 insertions, 164 deletions
diff --git a/src/frontend/app/api/arrivals.ts b/src/frontend/app/api/arrivals.ts
index 8ae6e78..ad99630 100644
--- a/src/frontend/app/api/arrivals.ts
+++ b/src/frontend/app/api/arrivals.ts
@@ -1,6 +1,8 @@
import {
StopArrivalsResponseSchema,
+ StopEstimatesResponseSchema,
type StopArrivalsResponse,
+ type StopEstimatesResponse,
} from "./schema";
export const fetchArrivals = async (
@@ -29,3 +31,31 @@ export const fetchArrivals = async (
throw e;
}
};
+
+export const fetchEstimates = async (
+ stopId: string,
+ routeId: string,
+ viaStopId?: string
+): Promise<StopEstimatesResponse> => {
+ let url = `/api/stops/estimates?stop=${encodeURIComponent(stopId)}&route=${encodeURIComponent(routeId)}`;
+ if (viaStopId) {
+ url += `&via=${encodeURIComponent(viaStopId)}`;
+ }
+
+ const resp = await fetch(url, {
+ headers: { Accept: "application/json" },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ const data = await resp.json();
+ try {
+ return StopEstimatesResponseSchema.parse(data);
+ } catch (e) {
+ console.error("Zod parsing failed for estimates:", e);
+ console.log("Received data:", data);
+ throw e;
+ }
+};
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index 0c55969..f68d413 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -64,10 +64,16 @@ export const ArrivalSchema = z.object({
shift: ShiftBadgeSchema.optional().nullable(),
shape: z.any().optional().nullable(),
currentPosition: PositionSchema.optional().nullable(),
- stopShapeIndex: z.number().optional().nullable(),
vehicleInformation: VehicleInformationSchema.optional().nullable(),
});
+export const ArrivalEstimateSchema = z.object({
+ tripId: z.string(),
+ patternId: z.string().optional().nullable(),
+ estimate: ArrivalDetailsSchema,
+ delay: DelayBadgeSchema.optional().nullable(),
+});
+
export const StopArrivalsResponseSchema = z.object({
stopCode: z.string(),
stopName: z.string(),
@@ -77,6 +83,10 @@ export const StopArrivalsResponseSchema = z.object({
usage: z.array(BusStopUsagePointSchema).optional().nullable(),
});
+export const StopEstimatesResponseSchema = z.object({
+ arrivals: z.array(ArrivalEstimateSchema),
+});
+
export type RouteInfo = z.infer<typeof RouteInfoSchema>;
export type HeadsignInfo = z.infer<typeof HeadsignInfoSchema>;
export type ArrivalPrecision = z.infer<typeof ArrivalPrecisionSchema>;
@@ -85,8 +95,10 @@ export type DelayBadge = z.infer<typeof DelayBadgeSchema>;
export type ShiftBadge = z.infer<typeof ShiftBadgeSchema>;
export type Position = z.infer<typeof PositionSchema>;
export type Arrival = z.infer<typeof ArrivalSchema>;
+export type ArrivalEstimate = z.infer<typeof ArrivalEstimateSchema>;
export type BusStopUsagePoint = z.infer<typeof BusStopUsagePointSchema>;
export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>;
+export type StopEstimatesResponse = z.infer<typeof StopEstimatesResponseSchema>;
// Transit Routes
export const RouteSchema = z.object({
diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx
index 30ac63f..8d3c6f8 100644
--- a/src/frontend/app/components/stop/StopMapModal.tsx
+++ b/src/frontend/app/components/stop/StopMapModal.tsx
@@ -15,14 +15,13 @@ import "./StopMapModal.css";
export interface Position {
latitude: number;
longitude: number;
- orientationDegrees: number;
- shapeIndex?: number;
+ orientationDegrees?: number | null;
+ shapeIndex?: number | null | undefined;
}
export interface ConsolidatedCirculationForMap {
id: string;
currentPosition?: Position;
- stopShapeIndex?: number;
colour: string;
textColour: string;
shape?: any;
diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts
index 43c8ae1..6f39f50 100644
--- a/src/frontend/app/data/PlannerApi.ts
+++ b/src/frontend/app/data/PlannerApi.ts
@@ -31,6 +31,8 @@ export interface Itinerary {
export interface Leg {
mode?: string;
feedId?: string;
+ routeId?: string;
+ tripId?: string;
routeName?: string;
routeShortName?: string;
routeLongName?: string;
diff --git a/src/frontend/app/hooks/useArrivals.ts b/src/frontend/app/hooks/useArrivals.ts
index e86a0bf..530ebc4 100644
--- a/src/frontend/app/hooks/useArrivals.ts
+++ b/src/frontend/app/hooks/useArrivals.ts
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { fetchArrivals } from "../api/arrivals";
+import { fetchArrivals, fetchEstimates } from "../api/arrivals";
export const useStopArrivals = (
stopId: string,
@@ -10,7 +10,22 @@ export const useStopArrivals = (
queryKey: ["arrivals", stopId, reduced],
queryFn: () => fetchArrivals(stopId, reduced),
enabled: !!stopId && enabled,
- refetchInterval: 15000, // Refresh every 15 seconds
- retry: false, // Disable retries to see errors immediately
+ refetchInterval: 15000,
+ retry: false,
+ });
+};
+
+export const useStopEstimates = (
+ stopId: string,
+ routeId: string,
+ viaStopId?: string,
+ enabled: boolean = true
+) => {
+ return useQuery({
+ queryKey: ["estimates", stopId, routeId, viaStopId],
+ queryFn: () => fetchEstimates(stopId, routeId, viaStopId),
+ enabled: !!stopId && !!routeId && enabled,
+ refetchInterval: 15000,
+ retry: false,
});
};
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index b7ecaf9..4038ef7 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { type ConsolidatedCirculation } from "~/api/schema";
+import { fetchEstimates } from "~/api/arrivals";
+import { type StopEstimatesResponse } from "~/api/schema";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import RouteIcon from "~/components/RouteIcon";
import { AppMap } from "~/components/shared/AppMap";
@@ -208,7 +209,7 @@ const ItineraryDetail = ({
const mapRef = useRef<MapRef>(null);
const { destination: userDestination } = usePlanner();
const [nextArrivals, setNextArrivals] = useState<
- Record<string, ConsolidatedCirculation[]>
+ Record<string, StopEstimatesResponse>
>({});
const routeGeoJson = {
@@ -324,27 +325,27 @@ const ItineraryDetail = ({
// Fetch next arrivals for bus legs
useEffect(() => {
- const fetchArrivals = async () => {
- const arrivalsByStop: Record<string, ConsolidatedCirculation[]> = {};
+ const fetchArrivalsForLegs = async () => {
+ const arrivalsByLeg: Record<string, StopEstimatesResponse> = {};
for (const leg of itinerary.legs) {
- if (leg.mode !== "WALK" && leg.from?.stopId) {
- const stopKey = leg.from.name || leg.from.stopId;
- if (!arrivalsByStop[stopKey]) {
+ if (
+ leg.mode !== "WALK" &&
+ leg.from?.stopId &&
+ leg.to?.stopId &&
+ leg.routeId
+ ) {
+ const legKey = `${leg.from.stopId}::${leg.to.stopId}`;
+ if (!arrivalsByLeg[legKey]) {
try {
- //TODO: Allow multiple stops one request
- const resp = await fetch(
- `/api/stops/arrivals?id=${encodeURIComponent(leg.from.stopId)}`,
- { headers: { Accept: "application/json" } }
+ arrivalsByLeg[legKey] = await fetchEstimates(
+ leg.from.stopId,
+ leg.routeId,
+ leg.to.stopId
);
-
- if (resp.ok) {
- arrivalsByStop[stopKey] =
- (await resp.json()) satisfies ConsolidatedCirculation[];
- }
} catch (err) {
console.warn(
- `Failed to fetch arrivals for ${leg.from.stopId}:`,
+ `Failed to fetch estimates for leg ${leg.from.stopId} -> ${leg.to.stopId}:`,
err
);
}
@@ -352,10 +353,10 @@ const ItineraryDetail = ({
}
}
- setNextArrivals(arrivalsByStop);
+ setNextArrivals(arrivalsByLeg);
};
- fetchArrivals();
+ fetchArrivalsForLegs();
}, [itinerary]);
return (
@@ -564,60 +565,45 @@ const ItineraryDetail = ({
</div>
{leg.mode !== "WALK" &&
leg.from?.stopId &&
- nextArrivals[leg.from.name || leg.from.stopId] && (
+ leg.to?.stopId &&
+ nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div className="font-semibold mb-1">
{t("planner.next_arrivals", "Next arrivals")}:
</div>
- {(() => {
- const currentLine =
- leg.routeShortName || leg.routeName;
- const previousLeg =
- idx > 0 ? itinerary.legs[idx - 1] : null;
- const previousLine =
- previousLeg?.mode !== "WALK"
- ? previousLeg?.routeShortName ||
- previousLeg?.routeName
- : null;
-
- const linesToShow = [currentLine];
- if (
- previousLine &&
- previousLeg?.to?.stopId === leg.from?.stopId
- ) {
- linesToShow.push(previousLine);
- }
-
- return nextArrivals[leg.from.stopId]
- ?.filter((circ) => linesToShow.includes(circ.line))
- .slice(0, 3)
- .map((circ, idx) => {
- const minutes =
- circ.realTime?.minutes ??
- circ.schedule?.minutes;
- if (minutes === undefined) return null;
- return (
- <div
- key={idx}
- className="flex items-center gap-2 py-0.5"
- >
- <span className="font-semibold">
- {circ.line}
- </span>
- <span className="text-gray-500 dark:text-gray-500">
- →
+ {nextArrivals[
+ `${leg.from.stopId}::${leg.to.stopId}`
+ ].arrivals
+ .slice(0, 3)
+ .map((arrival, i) => (
+ <div
+ key={`${arrival.tripId}-${i}`}
+ className="flex items-center gap-2 py-0.5"
+ >
+ <span className="font-semibold text-primary-600 dark:text-primary-400">
+ {formatDuration(arrival.estimate.minutes, t)}
+ </span>
+ {arrival.estimate.precision !== "scheduled" && (
+ <span className="text-green-600 dark:text-green-400">
+ 🟢
+ </span>
+ )}
+ {arrival.delay?.minutes !== undefined &&
+ arrival.delay.minutes !== 0 && (
+ <span
+ className={
+ arrival.delay.minutes > 0
+ ? "text-red-500"
+ : "text-green-500"
+ }
+ >
+ {arrival.delay.minutes > 0
+ ? `+${arrival.delay.minutes}′`
+ : `${arrival.delay.minutes}′`}
</span>
- <span className="flex-1 truncate">
- {circ.route}
- </span>
- <span className="font-semibold text-primary-600 dark:text-primary-400">
- {formatDuration(minutes, t)}
- {circ.realTime && " 🟢"}
- </span>
- </div>
- );
- });
- })()}
+ )}
+ </div>
+ ))}
</div>
)}
<div className="text-sm mt-1">
diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx
index 2174244..bccaf56 100644
--- a/src/frontend/app/routes/routes-$id.tsx
+++ b/src/frontend/app/routes/routes-$id.tsx
@@ -19,7 +19,7 @@ import {
Source,
type MapRef,
} from "react-map-gl/maplibre";
-import { Link, useParams } from "react-router";
+import { Link, useLocation, useNavigate, useParams } from "react-router";
import { fetchRouteDetails } from "~/api/transit";
import { AppMap } from "~/components/shared/AppMap";
import {
@@ -28,7 +28,7 @@ import {
usePageTitle,
usePageTitleNode,
} from "~/contexts/PageTitleContext";
-import { useStopArrivals } from "~/hooks/useArrivals";
+import { useStopEstimates } from "~/hooks/useArrivals";
import { useFavorites } from "~/hooks/useFavorites";
import { formatHex } from "~/utils/colours";
import "../tailwind-full.css";
@@ -59,9 +59,9 @@ function FavoriteStar({ id }: { id?: string }) {
export default function RouteDetailsPage() {
const { id } = useParams();
const { t, i18n } = useTranslation();
- const [selectedPatternId, setSelectedPatternId] = useState<string | null>(
- null
- );
+ const navigate = useNavigate();
+ const location = useLocation();
+ const selectedPatternId = location.hash ? location.hash.slice(1) : null;
const [selectedStopId, setSelectedStopId] = useState<string | null>(null);
const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">(
"balanced"
@@ -103,34 +103,13 @@ export default function RouteDetailsPage() {
queryFn: () => fetchRouteDetails(id!, selectedDateKey),
enabled: !!id,
});
- const { data: selectedStopRealtime, isLoading: isRealtimeLoading } =
- useStopArrivals(
+ const { data: selectedStopEstimates, isLoading: isRealtimeLoading } =
+ useStopEstimates(
selectedStopId ?? "",
- true,
- Boolean(selectedStopId) && isTodaySelectedDate
+ id ?? "",
+ undefined,
+ Boolean(selectedStopId) && Boolean(id) && isTodaySelectedDate
);
- const filteredRealtimeArrivals = useMemo(() => {
- const arrivals = selectedStopRealtime?.arrivals ?? [];
- if (arrivals.length === 0) {
- return [];
- }
-
- const routeId = id?.trim();
- const routeShortName = route?.shortName?.trim().toLowerCase();
-
- return arrivals.filter((arrival) => {
- const arrivalGtfsId = arrival.route.gtfsId?.trim();
- if (routeId && arrivalGtfsId) {
- return arrivalGtfsId === routeId;
- }
-
- if (routeShortName) {
- return arrival.route.shortName.trim().toLowerCase() === routeShortName;
- }
-
- return true;
- });
- }, [selectedStopRealtime?.arrivals, id, route?.shortName]);
usePageTitle(
route?.shortName
@@ -589,7 +568,10 @@ export default function RouteDetailsPage() {
key={pattern.id}
type="button"
onClick={() => {
- setSelectedPatternId(pattern.id);
+ navigate(
+ { hash: "#" + pattern.id },
+ { replace: true }
+ );
setSelectedStopId(null);
setIsPatternPickerOpen(false);
}}
@@ -748,33 +730,31 @@ export default function RouteDetailsPage() {
{selectedStopId === stop.id &&
(departuresByStop.get(stop.id)?.length ?? 0) > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
- {(
- departuresByStop
- .get(stop.id)
- ?.filter((item) =>
- isTodaySelectedDate
- ? item.departure >=
- nowSeconds - ONE_HOUR_SECONDS
- : true
- ) ?? []
- ).map((item, i) => (
- <span
- key={`${item.patternId}-${item.departure}-${i}`}
- className={`text-[11px] px-2 py-0.5 rounded ${
- item.patternId === selectedPattern?.id
- ? "bg-gray-100 dark:bg-gray-900"
- : "bg-gray-50 dark:bg-gray-900 text-gray-400 font-light"
- }`}
- >
- {Math.floor(item.departure / 3600)
- .toString()
- .padStart(2, "0")}
- :
- {Math.floor((item.departure % 3600) / 60)
- .toString()
- .padStart(2, "0")}
- </span>
- ))}
+ {(departuresByStop.get(stop.id) ?? []).map(
+ (item, i) => {
+ const isPast =
+ isTodaySelectedDate &&
+ item.departure < nowSeconds;
+ return (
+ <span
+ key={`${item.patternId}-${item.departure}-${i}`}
+ className={`text-[11px] px-2 py-0.5 rounded ${
+ item.patternId === selectedPattern?.id
+ ? "bg-gray-100 dark:bg-gray-900"
+ : "bg-gray-50 dark:bg-gray-900 text-gray-400 font-light"
+ } ${isPast ? "line-through opacity-50" : ""}`}
+ >
+ {Math.floor(item.departure / 3600)
+ .toString()
+ .padStart(2, "0")}
+ :
+ {Math.floor((item.departure % 3600) / 60)
+ .toString()
+ .padStart(2, "0")}
+ </span>
+ );
+ }
+ )}
</div>
)}
@@ -787,7 +767,8 @@ export default function RouteDetailsPage() {
<div className="text-[11px] text-muted">
{t("routes.loading_realtime", "Cargando...")}
</div>
- ) : filteredRealtimeArrivals.length === 0 ? (
+ ) : (selectedStopEstimates?.arrivals.length ?? 0) ===
+ 0 ? (
<div className="text-[11px] text-muted">
{t(
"routes.realtime_no_route_estimates",
@@ -796,37 +777,67 @@ export default function RouteDetailsPage() {
</div>
) : (
<>
- <div className="flex items-center justify-between gap-2 rounded-lg border border-green-500/30 bg-green-500/10 px-2.5 py-2">
- <span className="text-[11px] font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
- {t("routes.next_arrival", "Próximo")}
- </span>
- <span className="inline-flex min-w-16 items-center justify-center rounded-xl bg-green-600 px-3 py-1.5 text-base font-bold leading-none text-white">
- {filteredRealtimeArrivals[0].estimate.minutes}′
- {filteredRealtimeArrivals[0].delay?.minutes
- ? formatDelayMinutes(
- filteredRealtimeArrivals[0].delay.minutes
- )
- : ""}
- </span>
- </div>
+ {(() => {
+ const firstArrival =
+ selectedStopEstimates!.arrivals[0];
+ const isFirstSelectedPattern =
+ firstArrival.patternId === selectedPattern?.id;
+ return (
+ <div
+ className={`flex items-center justify-between gap-2 rounded-lg border px-2.5 py-2 ${isFirstSelectedPattern ? "border-green-500/30 bg-green-500/10" : "border-emerald-500/20 bg-emerald-500/5 opacity-50"}`}
+ >
+ <span
+ className={`text-[11px] font-semibold uppercase tracking-wide ${isFirstSelectedPattern ? "text-green-700 dark:text-green-300" : "text-emerald-700 dark:text-emerald-400"}`}
+ >
+ {t("routes.next_arrival", "Próximo")}
+ </span>
+ <span
+ className={`inline-flex min-w-16 items-center justify-center rounded-xl px-3 py-1.5 text-base font-bold leading-none text-white ${isFirstSelectedPattern ? "bg-green-600" : "bg-emerald-600"}`}
+ >
+ {firstArrival.estimate.minutes}′
+ {firstArrival.delay?.minutes
+ ? formatDelayMinutes(
+ firstArrival.delay.minutes
+ )
+ : ""}
+ </span>
+ </div>
+ );
+ })()}
- {filteredRealtimeArrivals.length > 1 && (
+ {selectedStopEstimates!.arrivals.length > 1 && (
<div className="mt-2 flex flex-wrap justify-end gap-1">
- {filteredRealtimeArrivals
+ {selectedStopEstimates!.arrivals
.slice(1)
- .map((arrival, i) => (
- <span
- key={`${arrival.tripId}-${i}`}
- className="text-[11px] px-2 py-0.5 bg-primary/10 text-primary rounded"
- >
- {arrival.estimate.minutes}′
- {arrival.delay?.minutes
- ? formatDelayMinutes(
- arrival.delay.minutes
- )
- : ""}
- </span>
- ))}
+ .map((arrival, i) => {
+ const isSelectedPattern =
+ arrival.patternId === selectedPattern?.id;
+ return (
+ <span
+ key={`${arrival.tripId}-${i}`}
+ className={`text-[11px] px-2 py-0.5 rounded ${
+ isSelectedPattern
+ ? "bg-gray-100 dark:bg-gray-900"
+ : "bg-gray-50 dark:bg-gray-900 text-gray-400 font-light"
+ }`}
+ title={
+ isSelectedPattern
+ ? undefined
+ : t(
+ "routes.other_pattern",
+ "Otro trayecto"
+ )
+ }
+ >
+ {arrival.estimate.minutes}′
+ {arrival.delay?.minutes
+ ? formatDelayMinutes(
+ arrival.delay.minutes
+ )
+ : ""}
+ </span>
+ );
+ })}
</div>
)}
</>
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index a61e925..4b32040 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -274,7 +274,6 @@ export default function Estimates() {
circulations={(data?.arrivals ?? []).map((a) => ({
id: getArrivalId(a),
currentPosition: a.currentPosition ?? undefined,
- stopShapeIndex: a.stopShapeIndex ?? undefined,
colour: formatHex(a.route.colour),
textColour: formatHex(a.route.textColour),
shape: a.shape,