diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-04 15:44:41 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-04 15:44:41 +0200 |
| commit | 97908d274ee12eb2301fadd5fc445d0f79479a56 (patch) | |
| tree | 04eee0ad547cc68047011dea82549dcad4a0d0d8 | |
| parent | 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (diff) | |
Enhance arrival and transit functionality with new vehicle operation logic and transit kind classification
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 28 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TileController.cs | 13 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs | 38 | ||||
| -rw-r--r-- | src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs | 19 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/arrivals/ArrivalCard.tsx | 122 | ||||
| -rw-r--r-- | src/frontend/app/components/arrivals/ReducedArrivalCard.tsx | 46 | ||||
| -rw-r--r-- | src/frontend/app/routes/home.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 3 |
9 files changed, 200 insertions, 72 deletions
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<Arrival> 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<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes) { List<StopTileResponse.Route> 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<ArrivalsAtStopContent.Args> 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<ArrivalCardProps> = ({ 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<ArrivalCardProps> = ({ 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<ArrivalCardProps> = ({ }); } - 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<ArrivalCardProps> = ({ {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<ArrivalCardProps> = ({ {chip.kind === "vehicle" && ( <BusFront className="w-3 h-3 inline-block" /> )} + {chip.kind === "pickup" && ( + <ArrowUpRightSquare className="w-3 h-3 inline-block" /> + )} + {chip.kind === "dropoff" && ( + <ArrowDownRightSquare className="w-3 h-3 inline-block" /> + )} + {chip.label} </span> ); })} - {onTrack && estimate.precision !== "past" && ( - // Use a <span> instead of a <button> here because this element can - // be rendered inside a <button> (when isClickable=true), and nested - // <button> elements are invalid HTML. - <span - role="button" - tabIndex={0} - onClick={(e) => { - e.stopPropagation(); - onTrack(); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {onTrack && + estimate.precision !== "past" && + estimate.precision !== "scheduled" && ( + // Use a <span> instead of a <button> here because this element can + // be rendered inside a <button> (when isClickable=true), and nested + // <button> elements are invalid HTML. + <span + role="button" + tabIndex={0} + onClick={(e) => { e.stopPropagation(); onTrack(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onTrack(); + } + }} + aria-label={ + isTracked + ? t("journey.stop_tracking", "Detener seguimiento") + : t("journey.track_bus", "Seguir este autobús") } - }} - aria-label={ - isTracked - ? t("journey.stop_tracking", "Detener seguimiento") - : t("journey.track_bus", "Seguir este autobús") - } - aria-pressed={isTracked} - className={`ml-auto text-xs px-2.5 py-0.5 rounded-full flex items-center gap-1 shrink-0 font-medium tracking-wide transition-colors cursor-pointer select-none ${ - isTracked - ? "bg-blue-600 text-white hover:bg-blue-700" - : "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400" - }`} - > - <Navigation className="w-3 h-3" /> - {isTracked - ? t("journey.tracking", "Siguiendo") - : t("journey.track", "Seguir")} - </span> - )} + aria-pressed={isTracked} + className={`ml-auto text-xs px-2.5 py-0.5 rounded-full flex items-center gap-1 shrink-0 font-medium tracking-wide transition-colors cursor-pointer select-none ${ + isTracked + ? "bg-blue-600 text-white hover:bg-blue-700" + : "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400" + }`} + > + <Navigation className="w-3 h-3" /> + {isTracked + ? t("journey.tracking", "Siguiendo") + : t("journey.track", "Seguir")} + </span> + )} </div> </Tag> ); diff --git a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx index 19cc8d9..6046ffc 100644 --- a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx @@ -1,4 +1,10 @@ -import { AlertTriangle, BusFront, LocateIcon } from "lucide-react"; +import { + AlertTriangle, + ArrowDownRightSquare, + ArrowUpRightSquare, + BusFront, + LocateIcon, +} from "lucide-react"; import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import RouteIcon from "~/components/RouteIcon"; @@ -23,10 +29,10 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ shift, vehicleInformation, operator, + operation, } = arrival; const etaValue = estimate.minutes.toString(); - const etaUnit = t("estimates.minutes", "min"); const timeClass = useMemo(() => { switch (estimate.precision) { @@ -45,9 +51,27 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ 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", + }); + } + if (operator) { chips.push({ label: operator, @@ -151,6 +175,7 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ headsign.badge, vehicleInformation, operator, + operation, ]); const isClickable = !!onClick && estimate.precision !== "past"; @@ -184,6 +209,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ {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"; @@ -223,6 +256,13 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ {chip.kind === "vehicle" && ( <BusFront className="w-3 h-3 my-0.5 inline-block" /> )} + {/** I tried imitating the tachograph symbols for loading/unloading, but "bottom right" was better distinguished compared to "bottom left" */} + {chip.kind === "pickup" && ( + <ArrowUpRightSquare className="w-3 h-3 my-0.5 inline-block" /> + )} + {chip.kind === "dropoff" && ( + <ArrowDownRightSquare className="w-3 h-3 my-0.5 inline-block" /> + )} {chip.label} </span> ); diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index e71c788..0a13fe6 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -240,8 +240,6 @@ export default function StopList() { </ul> </div> )} - - {/*<ServiceAlerts />*/} </> )} </div> diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index b3d7e86..2734895 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -10,6 +10,7 @@ import { type StopArrivalsResponse, } from "~/api/schema"; import { ArrivalList } from "~/components/arrivals/ArrivalList"; +import ServiceAlerts from "~/components/ServiceAlerts"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import { PullToRefresh } from "~/components/PullToRefresh"; import RouteIcon from "~/components/RouteIcon"; @@ -229,6 +230,8 @@ export default function Estimates() { </div> )} + <ServiceAlerts selectorFilter={[`stop#${stopId}`]} /> + <div className="estimates-list-container flex-1"> {dataLoading ? ( <>{/*TODO: New loading skeleton*/}</> |
