aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-01 11:26:27 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-01 11:26:27 +0100
commit9409e52a7fe14575966962c4fc3fbf248699cd96 (patch)
tree364ad679d948333f32e6a30713c5df7aa5eff9ad /src/frontend
parent3573ecae94aa328591d4b3a6e2d05e4fc9e261fc (diff)
Route detail visualisation improvement
Squashed commit of the following: commit 2f2261f764e0a0a52652bceda306f39f6f568b87 Author: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Date: Sun Mar 1 10:21:52 2026 +0000 Implement route-specific realtime filtering and route detail UI updates Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> commit e5ab68b158558e0f6577bf0fdd95e652fb269e6a Author: Ariel Costas Guerrero <ariel@costas.dev> Date: Sun Mar 1 11:17:19 2026 +0100 Fix delay formatting to ensure absolute values are displayed for arrivals and rejections commit df7d61c089a4e55a3b2efad8556b17e1f7f25e1c Author: Ariel Costas Guerrero <ariel@costas.dev> Date: Sun Mar 1 11:14:11 2026 +0100 Improve the formatting a bit for the arrival schedules commit 4b65cdb43824ba936234be6b5bdd6cf8ac9c56bb Author: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Date: Sun Mar 1 10:05:34 2026 +0000 Fix hook-order violation in route details stop departures Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> commit 2da4fb594f1433ddd1a26e267bbc7e917145b3b5 Author: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Date: Sun Mar 1 09:50:14 2026 +0000 Polish selected-stop realtime display in route details Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> commit dc7fc11085773a030bc9109e8c435a62a3567051 Author: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Date: Sun Mar 1 09:48:33 2026 +0000 Load route-details realtime only for selected stop Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> commit b9408664fd0c0d115f6aa0341deb9fa5b74f2b26 Author: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Date: Fri Feb 27 18:20:16 2026 +0000 Initial plan
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/api/schema.ts1
-rw-r--r--src/frontend/app/routes/routes-$id.tsx175
2 files changed, 167 insertions, 9 deletions
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index c0c97a4..57f34b1 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
export const RouteInfoSchema = z.object({
+ gtfsId: z.string().optional().nullable(),
shortName: z.string(),
colour: z.string(),
textColour: z.string(),
diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx
index 32f1fb7..6cc872d 100644
--- a/src/frontend/app/routes/routes-$id.tsx
+++ b/src/frontend/app/routes/routes-$id.tsx
@@ -24,6 +24,7 @@ import {
usePageTitle,
usePageTitleNode,
} from "~/contexts/PageTitleContext";
+import { useStopArrivals } from "~/hooks/useArrivals";
import { formatHex } from "~/utils/colours";
import "../tailwind-full.css";
@@ -55,12 +56,51 @@ export default function RouteDetailsPage() {
() => formatDateKey(selectedWeekDate),
[selectedWeekDate]
);
+ const ONE_HOUR_SECONDS = 3600;
+ const isTodaySelectedDate = selectedDateKey === formatDateKey(new Date());
+ const now = new Date();
+ const nowSeconds =
+ now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
+ const formatDelayMinutes = (delayMinutes: number) => {
+ if (delayMinutes === 0) return "OK";
+ return delayMinutes > 0
+ ? ` (R${Math.abs(delayMinutes)})`
+ : ` (A${Math.abs(delayMinutes)})`;
+ };
const { data: route, isLoading } = useQuery({
queryKey: ["route", id, selectedDateKey],
queryFn: () => fetchRouteDetails(id!, selectedDateKey),
enabled: !!id,
});
+ const { data: selectedStopRealtime, isLoading: isRealtimeLoading } =
+ useStopArrivals(
+ selectedStopId ?? "",
+ true,
+ Boolean(selectedStopId) && 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
@@ -161,6 +201,35 @@ export default function RouteDetailsPage() {
const selectedPatternLabel = selectedPattern
? selectedPattern.headsign || selectedPattern.name
: t("routes.details", "Detalles de ruta");
+ const sameDirectionPatterns = selectedPattern
+ ? (patternsByDirection[selectedPattern.directionId] ?? [])
+ : [];
+ const departuresByStop = (() => {
+ const byStop = new Map<
+ string,
+ { departure: number; patternId: string; tripId?: string | null }[]
+ >();
+
+ for (const pattern of sameDirectionPatterns) {
+ for (const stop of pattern.stops) {
+ const current = byStop.get(stop.id) ?? [];
+ current.push(
+ ...stop.scheduledDepartures.map((departure) => ({
+ departure,
+ patternId: pattern.id,
+ tripId: null,
+ }))
+ );
+ byStop.set(stop.id, current);
+ }
+ }
+
+ for (const stopDepartures of byStop.values()) {
+ stopDepartures.sort((a, b) => a.departure - b.departure);
+ }
+
+ return byStop;
+ })();
const mapHeightClass =
layoutMode === "map"
@@ -274,14 +343,33 @@ export default function RouteDetailsPage() {
{selectedPattern?.geometry && (
<Source type="geojson" data={geojson}>
<Layer
- id="route-line"
+ id="route-line-border"
+ type="line"
+ paint={{
+ "line-color":
+ route.textColor && route.textColor.trim()
+ ? formatHex(route.textColor)
+ : "#111827",
+ "line-width": 7,
+ "line-opacity": 0.75,
+ }}
+ layout={{
+ "line-cap": "round",
+ "line-join": "round",
+ }}
+ />
+ <Layer
+ id="route-line-inner"
type="line"
paint={{
"line-color": route.color
? formatHex(route.color)
: "#3b82f6",
- "line-width": 4,
- "line-opacity": 0.8,
+ "line-width": 5,
+ }}
+ layout={{
+ "line-cap": "round",
+ "line-join": "round",
}}
/>
</Source>
@@ -551,24 +639,93 @@ export default function RouteDetailsPage() {
)}
{selectedStopId === stop.id &&
- stop.scheduledDepartures.length > 0 && (
+ (departuresByStop.get(stop.id)?.length ?? 0) > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
- {stop.scheduledDepartures.map((dep, i) => (
+ {(
+ departuresByStop
+ .get(stop.id)
+ ?.filter((item) =>
+ isTodaySelectedDate
+ ? item.departure >=
+ nowSeconds - ONE_HOUR_SECONDS
+ : true
+ ) ?? []
+ ).map((item, i) => (
<span
- key={i}
- className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded"
+ 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(dep / 3600)
+ {Math.floor(item.departure / 3600)
.toString()
.padStart(2, "0")}
:
- {Math.floor((dep % 3600) / 60)
+ {Math.floor((item.departure % 3600) / 60)
.toString()
.padStart(2, "0")}
</span>
))}
</div>
)}
+
+ {selectedStopId === stop.id && isTodaySelectedDate && (
+ <div className="mt-2">
+ <div className="text-[10px] uppercase tracking-wide text-muted mb-1">
+ {t("routes.realtime", "Tiempo real")}
+ </div>
+ {isRealtimeLoading ? (
+ <div className="text-[11px] text-muted">
+ {t("routes.loading_realtime", "Cargando...")}
+ </div>
+ ) : filteredRealtimeArrivals.length === 0 ? (
+ <div className="text-[11px] text-muted">
+ {t(
+ "routes.realtime_no_route_estimates",
+ "Sin estimaciones para esta línea"
+ )}
+ </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>
+
+ {filteredRealtimeArrivals.length > 1 && (
+ <div className="mt-2 flex flex-wrap justify-end gap-1">
+ {filteredRealtimeArrivals
+ .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>
+ ))}
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ )}
</div>
</div>
))}