From 97908d274ee12eb2301fadd5fc445d0f79479a56 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sat, 4 Apr 2026 15:44:41 +0200 Subject: Enhance arrival and transit functionality with new vehicle operation logic and transit kind classification --- .../Controllers/ArrivalsController.cs | 28 ++++- src/Enmarcha.Backend/Controllers/TileController.cs | 13 +-- .../Helpers/TransitKindClassifier.cs | 38 +++++++ .../Queries/ArrivalsAtStop.cs | 19 ++-- src/frontend/app/api/schema.ts | 1 + .../app/components/arrivals/ArrivalCard.tsx | 122 +++++++++++++-------- .../app/components/arrivals/ReducedArrivalCard.tsx | 46 +++++++- src/frontend/app/routes/home.tsx | 2 - src/frontend/app/routes/stops-$id.tsx | 3 + 9 files changed, 200 insertions(+), 72 deletions(-) create mode 100644 src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index 16bc047..7feeee0 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -140,6 +140,15 @@ public partial class ArrivalsController : ControllerBase return Ok(new StopEstimatesResponse { Arrivals = estimates }); } + private static VehicleOperation GetVehicleOperation(ArrivalsAtStopResponse.PickupType pickup, ArrivalsAtStopResponse.PickupType dropoff) + { + if (pickup == ArrivalsAtStopResponse.PickupType.None && dropoff == ArrivalsAtStopResponse.PickupType.None) return VehicleOperation.PickupDropoff; + if (pickup != ArrivalsAtStopResponse.PickupType.None && dropoff != ArrivalsAtStopResponse.PickupType.None) return VehicleOperation.PickupDropoff; + if (pickup != ArrivalsAtStopResponse.PickupType.None) return VehicleOperation.PickupOnly; + if (dropoff != ArrivalsAtStopResponse.PickupType.None) return VehicleOperation.DropoffOnly; + return VehicleOperation.PickupDropoff; + } + private async Task<(ArrivalsAtStopResponse.StopItem Stop, ArrivalsContext Context)?> FetchAndProcessArrivalsAsync( string id, bool reduced, bool nano) { @@ -168,11 +177,21 @@ public partial class ArrivalsController : ControllerBase List arrivals = []; foreach (var item in stop.Arrivals) { - if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) continue; + //if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) continue; + //if ( + // item.Trip.ArrivalStoptime.Stop.GtfsId == id && + // item.Trip.DepartureStoptime.Stop.GtfsId != id + //) continue; + + // Delete loop routes that aren't starting here if ( item.Trip.ArrivalStoptime.Stop.GtfsId == id && - item.Trip.DepartureStoptime.Stop.GtfsId != id - ) continue; + item.Trip.DepartureStoptime.Stop.GtfsId == id && + item.StopPosition != 1 + ) + { + continue; + } var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz); var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds); @@ -195,7 +214,8 @@ public partial class ArrivalsController : ControllerBase Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled }, Operator = feedId == "xunta" ? item.Trip.Route.Agency?.Name : null, - RawOtpTrip = item + RawOtpTrip = item, + Operation = GetVehicleOperation(item.PickupTypeParsed, item.DropoffTypeParsed) }); } diff --git a/src/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs index 5ef8dd6..b419dee 100644 --- a/src/Enmarcha.Backend/Controllers/TileController.cs +++ b/src/Enmarcha.Backend/Controllers/TileController.cs @@ -127,7 +127,7 @@ public class TileController : ControllerBase { "code", $"{idParts[0]}:{codeWithinFeed}" }, { "name", FeedService.NormalizeStopName(feedId, stop.Name) }, { "icon", GetIconNameForFeed(feedId) }, - { "transitKind", GetTransitKind(feedId) } + { "transitKind", TransitKindClassifier.StringByFeed(feedId) } } }; @@ -172,17 +172,6 @@ public class TileController : ControllerBase }; } - private string GetTransitKind(string feedId) - { - return feedId switch - { - "vitrasa" or "tussa" or "tranvias" or "shuttle" or "ourense" => "bus", - "xunta" => "coach", - "renfe" or "feve" => "train", - _ => "unknown" - }; - } - private List GetDistinctRoutes(string feedId, List routes) { List distinctRoutes = []; diff --git a/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs b/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs new file mode 100644 index 0000000..5caf9fc --- /dev/null +++ b/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace Enmarcha.Backend.Helpers; + +public class TransitKindClassifier +{ + public static TransitKind KindByFeed(string feedId) + { + return feedId switch + { + "vitrasa" or "tussa" or "tranvias" or "shuttle" or "ourense" => TransitKind.Bus, + "xunta" => TransitKind.Coach, + "renfe" or "feve" => TransitKind.Train, + _ => TransitKind.Unknown + }; + } + + public static string StringByFeed(string feedId) + { + var kind = KindByFeed(feedId); + return kind switch + { + TransitKind.Bus => "bus", + TransitKind.Coach => "coach", + TransitKind.Train => "train", + TransitKind.Unknown => "unknown", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + } +} + +public enum TransitKind +{ + [JsonStringEnumMemberName("bus")] Bus, + [JsonStringEnumMemberName("coach")] Coach, + [JsonStringEnumMemberName("train")] Train, + [JsonStringEnumMemberName("unknown")] Unknown +} diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs index 7605e5a..cc0f4e6 100644 --- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs +++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs @@ -31,8 +31,9 @@ public class ArrivalsAtStopContent : IGraphRequest headsign scheduledDeparture serviceDay + stopPosition pickupType - + dropoffType trip {{ gtfsId serviceId @@ -108,13 +109,16 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("scheduledDeparture")] public int ScheduledDepartureSeconds { get; set; } - [JsonPropertyName("serviceDay")] - public long ServiceDay { get; set; } + [JsonPropertyName("serviceDay")] public long ServiceDay { get; set; } [JsonPropertyName("pickupType")] public required string PickupTypeOriginal { get; set; } - public PickupType PickupTypeParsed => PickupType.Parse(PickupTypeOriginal); + [JsonPropertyName("dropoffType")] public required string DropoffTypeOriginal { get; set; } + public PickupType DropoffTypeParsed => PickupType.Parse(DropoffTypeOriginal); + + [JsonPropertyName("stopPosition")] public int StopPosition { get; set; } + [JsonPropertyName("trip")] public required TripDetails Trip { get; set; } } @@ -131,8 +135,7 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("departureStoptime")] public required TerminusStoptime DepartureStoptime { get; set; } - [JsonPropertyName("arrivalStoptime")] - public required TerminusStoptime ArrivalStoptime { get; set; } + [JsonPropertyName("arrivalStoptime")] public required TerminusStoptime ArrivalStoptime { get; set; } [JsonPropertyName("route")] public required RouteDetails Route { get; set; } @@ -156,7 +159,9 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public class StoptimeDetails { [JsonPropertyName("stop")] public required StopDetails Stop { get; set; } - [JsonPropertyName("scheduledDeparture")] public int ScheduledDeparture { get; set; } + + [JsonPropertyName("scheduledDeparture")] + public int ScheduledDeparture { get; set; } } public class StopDetails diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 40358a6..4d34a44 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -66,6 +66,7 @@ export const ArrivalSchema = z.object({ currentPosition: PositionSchema.optional().nullable(), vehicleInformation: VehicleInformationSchema.optional().nullable(), operator: z.string().nullable(), + operation: z.enum(["pickup_dropoff", "pickup_only", "dropoff_only"]), }); export const ArrivalEstimateSchema = z.object({ diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx index ec14492..827599e 100644 --- a/src/frontend/app/components/arrivals/ArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -1,4 +1,11 @@ -import { AlertTriangle, BusFront, LocateIcon, Navigation } from "lucide-react"; +import { + AlertTriangle, + ArrowDownRightSquare, + ArrowUpRightSquare, + BusFront, + LocateIcon, + Navigation, +} from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; @@ -71,10 +78,10 @@ export const ArrivalCard: React.FC = ({ shift, vehicleInformation, operator, + operation, } = arrival; const etaValue = estimate.minutes.toString(); - const etaUnit = t("estimates.minutes", "min"); const timeClass = useMemo(() => { switch (estimate.precision) { @@ -93,9 +100,27 @@ export const ArrivalCard: React.FC = ({ const chips: Array<{ label: string; tone?: string; - kind?: "regular" | "gps" | "delay" | "warning" | "vehicle"; + kind?: + | "regular" + | "gps" + | "delay" + | "warning" + | "vehicle" + | "pickup" + | "dropoff"; }> = []; + if (operation !== "pickup_dropoff") { + chips.push({ + label: + operation === "pickup_only" + ? t("journey.pickup_only", "Solo subida") + : t("journey.dropoff_only", "Solo bajada"), + tone: operation === "pickup_only" ? "pickup" : "dropoff", + kind: operation === "pickup_only" ? "pickup" : "dropoff", + }); + } + // Badge/Shift info as a chip if (headsign.badge) { chips.push({ @@ -154,14 +179,6 @@ export const ArrivalCard: React.FC = ({ }); } - if (estimate.precision === "scheduled") { - chips.push({ - label: t("estimates.no_realtime"), - tone: "warning", - kind: "warning", - }); - } - // Vehicle information if available if (vehicleInformation) { let label = vehicleInformation.identifier; @@ -240,6 +257,14 @@ export const ArrivalCard: React.FC = ({ {metaChips.map((chip, idx) => { let chipColourClasses = ""; switch (chip.tone) { + case "pickup": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "dropoff": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; case "delay-ok": chipColourClasses = "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; @@ -279,47 +304,56 @@ export const ArrivalCard: React.FC = ({ {chip.kind === "vehicle" && ( )} + {chip.kind === "pickup" && ( + + )} + {chip.kind === "dropoff" && ( + + )} + {chip.label} ); })} - {onTrack && estimate.precision !== "past" && ( - // Use a instead of a