summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-04 15:44:41 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-04 15:44:41 +0200
commit97908d274ee12eb2301fadd5fc445d0f79479a56 (patch)
tree04eee0ad547cc68047011dea82549dcad4a0d0d8
parent1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (diff)
Enhance arrival and transit functionality with new vehicle operation logic and transit kind classification
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs28
-rw-r--r--src/Enmarcha.Backend/Controllers/TileController.cs13
-rw-r--r--src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs38
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs19
-rw-r--r--src/frontend/app/api/schema.ts1
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx122
-rw-r--r--src/frontend/app/components/arrivals/ReducedArrivalCard.tsx46
-rw-r--r--src/frontend/app/routes/home.tsx2
-rw-r--r--src/frontend/app/routes/stops-$id.tsx3
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*/}</>