aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-23 12:59:52 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-23 13:00:16 +0100
commit87417c313b455ba0dee19708528cc8d0b830a276 (patch)
tree34b7a2d6bb97157a1d35f57be85b8ff6532865d2
parentbed48c3d7e49b1736d50ce42d92bb6c18cf02504 (diff)
Reimplement real time for Vitrasa
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs53
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/TileController.cs62
-rw-r--r--src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs52
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs10
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs58
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FeedService.cs189
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs84
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs44
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs26
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs34
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs97
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs158
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs13
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx136
-rw-r--r--src/frontend/app/components/arrivals/ArrivalList.tsx4
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx2
-rw-r--r--src/frontend/app/config/constants.ts4
17 files changed, 930 insertions, 96 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs
index 7158137..934935e 100644
--- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs
+++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs
@@ -1,6 +1,7 @@
using System.Net;
using Costasdev.Busurbano.Backend.GraphClient;
using Costasdev.Busurbano.Backend.GraphClient.App;
+using Costasdev.Busurbano.Backend.Services;
using Costasdev.Busurbano.Backend.Types.Arrivals;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
@@ -14,16 +15,22 @@ public partial class ArrivalsController : ControllerBase
private readonly ILogger<ArrivalsController> _logger;
private readonly IMemoryCache _cache;
private readonly HttpClient _httpClient;
+ private readonly ArrivalsPipeline _pipeline;
+ private readonly FeedService _feedService;
public ArrivalsController(
ILogger<ArrivalsController> logger,
IMemoryCache cache,
- HttpClient httpClient
+ HttpClient httpClient,
+ ArrivalsPipeline pipeline,
+ FeedService feedService
)
{
_logger = logger;
_cache = cache;
_httpClient = httpClient;
+ _pipeline = pipeline;
+ _feedService = feedService;
}
[HttpGet("arrivals")]
@@ -37,11 +44,7 @@ public partial class ArrivalsController : ControllerBase
var todayLocal = nowLocal.Date;
var requestContent = ArrivalsAtStopContent.Query(
- new ArrivalsAtStopContent.Args(
- id,
- reduced ? 4 : 10,
- ShouldFetchPastArrivals(id)
- )
+ new ArrivalsAtStopContent.Args(id, reduced)
);
var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1");
@@ -60,10 +63,26 @@ public partial class ArrivalsController : ControllerBase
}
var stop = responseBody.Data.Stop;
+ _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id);
+
List<Arrival> arrivals = [];
foreach (var item in stop.Arrivals)
{
- var departureTime = todayLocal.AddSeconds(item.ScheduledDepartureSeconds);
+ if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None))
+ {
+ continue;
+ }
+
+ if (item.Trip.Geometry?.Points != null)
+ {
+ _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId);
+ }
+
+ // Calculate departure time using the service day in the feed's timezone (Europe/Madrid)
+ // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day
+ var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz);
+ var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds);
+
var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes;
//var isRunning = departureTime < nowLocal;
@@ -89,17 +108,29 @@ public partial class ArrivalsController : ControllerBase
Estimate = new ArrivalDetails
{
Minutes = minutesToArrive,
- Precision = departureTime < nowLocal ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled
- }
+ Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled
+ },
+ RawOtpTrip = item
};
arrivals.Add(arrival);
}
- return Ok(new StopArrivalsResponse
+ await _pipeline.ExecuteAsync(new ArrivalsContext
{
+ StopId = id,
StopCode = stop.Code,
- StopName = stop.Name,
+ IsReduced = reduced,
+ Arrivals = arrivals,
+ NowLocal = nowLocal
+ });
+
+ var feedId = id.Split(':')[0];
+
+ return Ok(new StopArrivalsResponse
+ {
+ StopCode = _feedService.NormalizeStopCode(feedId, stop.Code),
+ StopName = _feedService.NormalizeStopName(feedId, stop.Name),
Arrivals = arrivals
});
}
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs
index 6354d67..0e9d21b 100644
--- a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs
+++ b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;
using Costasdev.Busurbano.Backend.Helpers;
+using Costasdev.Busurbano.Backend.Services;
namespace Costasdev.Busurbano.Backend.Controllers;
@@ -20,29 +21,21 @@ public class TileController : ControllerBase
private readonly ILogger<TileController> _logger;
private readonly IMemoryCache _cache;
private readonly HttpClient _httpClient;
+ private readonly FeedService _feedService;
public TileController(
ILogger<TileController> logger,
IMemoryCache cache,
- HttpClient httpClient
+ HttpClient httpClient,
+ FeedService feedService
)
{
_logger = logger;
_cache = cache;
_httpClient = httpClient;
+ _feedService = feedService;
}
- private static readonly string[] HiddenStops =
- [
- "vitrasa:20223", // Castrelos (Pavillón - U1)
- "vitrasa:20146", // García Barbón, 7 (A, 18A)
- "vitrasa:20220", // COIA-SAMIL (15)
- "vitrasa:20001", // Samil por Beiramar (15B)
- "vitrasa:20002", // Samil por Torrecedeira (15C)
- "vitrasa:20144", // Samil por Coia (C3d, C3i)
- "vitrasa:20145" // Samil por Bouzs (C3d, C3i)
- ];
-
[HttpGet("stops/{z:int}/{x:int}/{y:int}")]
public async Task<IActionResult> Stops(int z, int x, int y)
{
@@ -97,24 +90,14 @@ public class TileController : ControllerBase
{
var idParts = stop.GtfsId.Split(':', 2);
string feedId = idParts[0];
- string codeWithinFeed = stop.Code ?? string.Empty;
+ string codeWithinFeed = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty);
- // TODO: Refactor this, maybe do it client-side or smth
- if (feedId == "vitrasa")
- {
- var digits = new string(codeWithinFeed.Where(char.IsDigit).ToArray());
- if (int.TryParse(digits, out int code))
- {
- codeWithinFeed = code.ToString();
- }
- }
-
- if (HiddenStops.Contains($"{feedId}:{codeWithinFeed}"))
+ if (_feedService.IsStopHidden($"{feedId}:{codeWithinFeed}"))
{
return;
}
- var (Color, TextColor) = GetFallbackColourForFeed(idParts[0]);
+ var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]);
var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []);
Feature feature = new()
@@ -129,7 +112,7 @@ public class TileController : ControllerBase
// The public identifier, usually feed:code or feed:id, recognisable by users and in other systems
{ "code", $"{idParts[0]}:{codeWithinFeed}" },
// The name of the stop
- { "name", stop.Name },
+ { "name", _feedService.NormalizeStopName(feedId, stop.Name) },
// Routes
{ "routes", JsonSerializer
.Serialize(
@@ -176,21 +159,15 @@ public class TileController : ControllerBase
return File(ms.ToArray(), "application/x-protobuf");
}
- private static List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes)
+ private List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes)
{
List<StopTileResponse.Route> distinctRoutes = [];
HashSet<string> seen = new();
foreach (var route in routes)
{
- var seenId = route.ShortName;
- if (feedId == "xunta")
- {
- // For Xunta routes we take only the contract number (XG123, for example)
- seenId = seenId.Substring(0, 5);
-
- route.ShortName = seenId;
- }
+ var seenId = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty);
+ route.ShortName = seenId;
if (seen.Contains(seenId))
{
@@ -206,19 +183,4 @@ public class TileController : ControllerBase
Comparer<string?>.Create(SortingHelper.SortRouteShortNames)
)];
}
-
- private static (string Color, string TextColor) GetFallbackColourForFeed(string feed)
- {
- return feed switch
- {
- "vitrasa" => ("#95D516", "#000000"),
- "santiago" => ("#508096", "#FFFFFF"),
- "coruna" => ("#E61C29", "#FFFFFF"),
- "xunta" => ("#007BC4", "#FFFFFF"),
- "renfe" => ("#870164", "#FFFFFF"),
- "feve" => ("#EE3D32", "#FFFFFF"),
- _ => ("#000000", "#FFFFFF"),
-
- };
- }
}
diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs
index 2c34784..cf2907c 100644
--- a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs
+++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs
@@ -5,28 +5,25 @@ namespace Costasdev.Busurbano.Backend.GraphClient.App;
public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args>
{
- public const int PastArrivalMinutesIncluded = -15;
+ public const int PastArrivalMinutesIncluded = -75;
- public record Args(string Id, int DepartureCount, bool PastArrivals);
+ public record Args(string Id, bool Reduced);
public static string Query(Args args)
{
- var startTime = DateTimeOffset.Now;
- if (args.PastArrivals)
- {
- startTime = DateTimeOffset.Now.AddMinutes(PastArrivalMinutesIncluded);
- }
-
+ var startTime = DateTimeOffset.UtcNow.AddMinutes(PastArrivalMinutesIncluded);
var startTimeUnix = startTime.ToUnixTimeSeconds();
+ var geometryField = args.Reduced ? "" : @"tripGeometry { points }";
return string.Create(CultureInfo.InvariantCulture, $@"
query Query {{
stop(id:""{args.Id}"") {{
code
name
- arrivals: stoptimesWithoutPatterns(numberOfDepartures:{args.DepartureCount}, startTime: {startTimeUnix}) {{
+ arrivals: stoptimesWithoutPatterns(numberOfDepartures: 100, startTime: {startTimeUnix}, timeRange: 14400) {{
headsign
scheduledDeparture
+ serviceDay
pickupType
trip {{
@@ -36,10 +33,18 @@ public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args>
route {{
color
textColor
+ longName
}}
departureStoptime {{
scheduledDeparture
}}
+ {geometryField}
+ stoptimes {{
+ stop {{
+ name
+ }}
+ scheduledDeparture
+ }}
}}
}}
}}
@@ -68,9 +73,12 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
[JsonPropertyName("scheduledDeparture")]
public int ScheduledDepartureSeconds { get; set; }
+ [JsonPropertyName("serviceDay")]
+ public long ServiceDay { get; set; }
+
[JsonPropertyName("pickupType")] public required string PickupTypeOriginal { get; set; }
- public PickupType PickupTypeParsed => PickupTypeParsed.Parse(PickupTypeOriginal);
+ public PickupType PickupTypeParsed => PickupType.Parse(PickupTypeOriginal);
[JsonPropertyName("trip")] public required TripDetails Trip { get; set; }
}
@@ -87,6 +95,26 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
public required DepartureStoptime DepartureStoptime { get; set; }
[JsonPropertyName("route")] public required RouteDetails Route { get; set; }
+
+ [JsonPropertyName("tripGeometry")] public GeometryDetails? Geometry { get; set; }
+
+ [JsonPropertyName("stoptimes")] public List<StoptimeDetails> Stoptimes { get; set; } = [];
+ }
+
+ public class GeometryDetails
+ {
+ [JsonPropertyName("points")] public string? Points { get; set; }
+ }
+
+ public class StoptimeDetails
+ {
+ [JsonPropertyName("stop")] public required StopDetails Stop { get; set; }
+ [JsonPropertyName("scheduledDeparture")] public int ScheduledDeparture { get; set; }
+ }
+
+ public class StopDetails
+ {
+ [JsonPropertyName("name")] public required string Name { get; set; }
}
public class DepartureStoptime
@@ -100,6 +128,8 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
[JsonPropertyName("color")] public string? Color { get; set; }
[JsonPropertyName("textColor")] public string? TextColor { get; set; }
+
+ [JsonPropertyName("longName")] public string? LongName { get; set; }
}
public class PickupType
@@ -111,7 +141,7 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
_value = value;
}
- public PickupType Parse(string value)
+ public static PickupType Parse(string value)
{
return value switch
{
diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs
index 70372e8..c34f00e 100644
--- a/src/Costasdev.Busurbano.Backend/Program.cs
+++ b/src/Costasdev.Busurbano.Backend/Program.cs
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using Costasdev.Busurbano.Backend.Configuration;
using Costasdev.Busurbano.Backend.Services;
+using Costasdev.Busurbano.Backend.Services.Processors;
using Costasdev.Busurbano.Backend.Services.Providers;
var builder = WebApplication.CreateBuilder(args);
@@ -17,6 +18,15 @@ builder.Services
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ShapeTraversalService>();
+builder.Services.AddSingleton<FeedService>();
+
+builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, FilterAndSortProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, NextStopsProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, MarqueeProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, FeedConfigProcessor>();
+builder.Services.AddScoped<ArrivalsPipeline>();
builder.Services.AddHttpClient<OtpService>();
builder.Services.AddScoped<VitrasaTransitProvider>();
diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
new file mode 100644
index 0000000..8699a1e
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
@@ -0,0 +1,58 @@
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class ArrivalsContext
+{
+ /// <summary>
+ /// The full GTFS ID of the stop (e.g., "vitrasa:1400")
+ /// </summary>
+ public required string StopId { get; set; }
+
+ /// <summary>
+ /// The public code of the stop (e.g., "1400")
+ /// </summary>
+ public required string StopCode { get; set; }
+
+ /// <summary>
+ /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10)
+ /// </summary>
+ public bool IsReduced { get; set; }
+
+ public required List<Arrival> Arrivals { get; set; }
+ public required DateTime NowLocal { get; set; }
+}
+
+public interface IArrivalsProcessor
+{
+ /// <summary>
+ /// Processes the arrivals in the context. Processors are executed in the order they are registered.
+ /// </summary>
+ Task ProcessAsync(ArrivalsContext context);
+}
+
+/// <summary>
+/// Orchestrates the enrichment of arrival data through a series of processors.
+/// This follows a pipeline pattern where each step (processor) adds or modifies data
+/// in the shared ArrivalsContext.
+/// </summary>
+public class ArrivalsPipeline
+{
+ private readonly IEnumerable<IArrivalsProcessor> _processors;
+
+ public ArrivalsPipeline(IEnumerable<IArrivalsProcessor> processors)
+ {
+ _processors = processors;
+ }
+
+ /// <summary>
+ /// Executes all registered processors sequentially.
+ /// </summary>
+ public async Task ExecuteAsync(ArrivalsContext context)
+ {
+ foreach (var processor in _processors)
+ {
+ await processor.ProcessAsync(context);
+ }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
new file mode 100644
index 0000000..48f9338
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
@@ -0,0 +1,189 @@
+using System.Text.RegularExpressions;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class FeedService
+{
+ private static readonly Regex RemoveQuotationMarks = new(@"[""”]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex StreetNameRegex = new(@"^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Dictionary<string, string> NameReplacements = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "Rúa da Salguera Entrada", "Rúa da Salgueira" },
+ { "Rúa da Salgueira Entrada", "Rúa da Salgueira" },
+ { "Estrada de Miraflores", "Estrada Miraflores" },
+ { "FORA DE SERVIZO.G.B.", "" },
+ { "Praza de Fernando O Católico", "" },
+ { "Rúa da Travesía de Vigo", "Travesía de Vigo" },
+ { "Rúa de ", " " },
+ { "Rúa do ", " " },
+ { "Rúa da ", " " },
+ { "Rúa das ", " " },
+ { "Avda. de ", " " },
+ { "Avda. do ", " " },
+ { "Avda. da ", " " },
+ { "Avda. das ", " " },
+ { "Riós", "Ríos" },
+ { "Avda. Beiramar Porto Pesqueiro Berbés", "Berbés" },
+ { "Conde de Torrecedeira", "Torrecedeira" }
+ };
+
+ public (string Color, string TextColor) GetFallbackColourForFeed(string feed)
+ {
+ return feed switch
+ {
+ "vitrasa" => ("#95D516", "#000000"),
+ "santiago" => ("#508096", "#FFFFFF"),
+ "coruna" => ("#E61C29", "#FFFFFF"),
+ "xunta" => ("#007BC4", "#FFFFFF"),
+ "renfe" => ("#870164", "#FFFFFF"),
+ "feve" => ("#EE3D32", "#FFFFFF"),
+ _ => ("#000000", "#FFFFFF"),
+ };
+ }
+
+ public string NormalizeStopCode(string feedId, string code)
+ {
+ if (feedId == "vitrasa")
+ {
+ var digits = new string(code.Where(char.IsDigit).ToArray());
+ if (int.TryParse(digits, out int numericCode))
+ {
+ return numericCode.ToString();
+ }
+ }
+ return code;
+ }
+
+ public string NormalizeRouteShortName(string feedId, string shortName)
+ {
+ if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8)
+ {
+ // XG817014 -> 817.14
+ var contract = shortName.Substring(2, 3);
+ var lineStr = shortName.Substring(5);
+ if (int.TryParse(lineStr, out int line))
+ {
+ return $"{contract}.{line}";
+ }
+ }
+ return shortName;
+ }
+
+ public string NormalizeStopName(string feedId, string name)
+ {
+ if (feedId == "vitrasa")
+ {
+ return name
+ .Replace("\"", "")
+ .Replace(" ", ", ")
+ .Trim();
+ }
+
+ return name;
+ }
+
+ public string NormalizeRouteNameForMatching(string name)
+ {
+ var normalized = name.Trim().ToLowerInvariant();
+ // Remove diacritics/accents
+ normalized = Regex.Replace(normalized.Normalize(System.Text.NormalizationForm.FormD), @"\p{Mn}", "");
+ // Keep only alphanumeric
+ return Regex.Replace(normalized, @"[^a-z0-9]", "");
+ }
+
+ public string GetStreetName(string originalName)
+ {
+ var name = RemoveQuotationMarks.Replace(originalName, "").Trim();
+ var match = StreetNameRegex.Match(name);
+ var streetName = match.Success ? match.Groups[1].Value : name;
+
+ foreach (var replacement in NameReplacements)
+ {
+ if (streetName.Contains(replacement.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ streetName = streetName.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase);
+ return streetName.Trim();
+ }
+ }
+
+ return streetName.Trim();
+ }
+
+ public string? GenerateMarquee(string feedId, List<string> nextStops)
+ {
+ if (nextStops.Count == 0) return null;
+
+ if (feedId == "vitrasa")
+ {
+ var streets = nextStops
+ .Select(GetStreetName)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Distinct()
+ .ToList();
+
+ return string.Join(" - ", streets);
+ }
+
+ return feedId switch
+ {
+ "xunta" => string.Join(" > ", nextStops),
+ _ => string.Join(", ", nextStops.Take(4))
+ };
+ }
+
+ public bool IsStopHidden(string stopId)
+ {
+ return HiddenStops.Contains(stopId);
+ }
+
+ public ShiftBadge? GetShiftBadge(string feedId, string tripId)
+ {
+ if (feedId != "vitrasa") return null;
+
+ // Example: C1 04LN 02_001004_4
+ var parts = tripId.Split('_');
+ if (parts.Length < 2) return null;
+
+ var shiftGroup = parts[parts.Length - 2]; // 001004
+ var tripNumber = parts[parts.Length - 1]; // 4
+
+ if (shiftGroup.Length != 6) return null;
+
+ if (!int.TryParse(shiftGroup.Substring(0, 3), out var routeNum)) return null;
+ if (!int.TryParse(shiftGroup.Substring(3, 3), out var shiftNum)) return null;
+
+ var routeName = routeNum switch
+ {
+ 1 => "C1",
+ 3 => "C3",
+ 30 => "N1",
+ 33 => "N4",
+ 8 => "A",
+ 101 => "H",
+ 201 => "U1",
+ 202 => "U2",
+ 150 => "REF",
+ 500 => "TUR",
+ _ => $"L{routeNum}"
+ };
+
+ return new ShiftBadge
+ {
+ ShiftName = $"{routeName}-{shiftNum}",
+ ShiftTrip = tripNumber
+ };
+ }
+
+ private static readonly string[] HiddenStops =
+ [
+ "vitrasa:20223", // Castrelos (Pavillón - U1)
+ "vitrasa:20146", // García Barbón, 7 (A, 18A)
+ "vitrasa:20220", // COIA-SAMIL (15)
+ "vitrasa:20001", // Samil por Beiramar (15B)
+ "vitrasa:20002", // Samil por Torrecedeira (15C)
+ "vitrasa:20144", // Samil por Coia (C3d, C3i)
+ "vitrasa:20145" // Samil por Bouzs (C3d, C3i)
+ ];
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
new file mode 100644
index 0000000..fde3e0a
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs
@@ -0,0 +1,84 @@
+using Costasdev.Busurbano.Backend.Helpers;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class FeedConfigProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public FeedConfigProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+ var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId);
+
+ foreach (var arrival in context.Arrivals)
+ {
+ arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName);
+ arrival.Headsign.Destination = _feedService.NormalizeStopName(feedId, arrival.Headsign.Destination);
+
+ // Apply Vitrasa-specific line formatting
+ if (feedId == "vitrasa")
+ {
+ FormatVitrasaLine(arrival);
+ arrival.Shift = _feedService.GetShiftBadge(feedId, arrival.TripId);
+ }
+
+ if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF")
+ {
+ arrival.Route.Colour = fallbackColor;
+ arrival.Route.TextColour = fallbackTextColor;
+ }
+ else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000")
+ {
+ arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static void FormatVitrasaLine(Arrival arrival)
+ {
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", "");
+
+ if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.")
+ {
+ arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)";
+ return;
+ }
+
+ switch (arrival.Route.ShortName)
+ {
+ case "A" when arrival.Headsign.Destination.StartsWith("\"1\""):
+ arrival.Route.ShortName = "A1";
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"1\"", "");
+ break;
+ case "6":
+ arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", "");
+ break;
+ case "FUT":
+ if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO")
+ {
+ arrival.Route.ShortName = "MAR";
+ arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO";
+ }
+ else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA")
+ {
+ arrival.Route.ShortName = "RIO";
+ arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA";
+ }
+ else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO")
+ {
+ arrival.Route.ShortName = "GOL";
+ arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO";
+ }
+ break;
+ }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs
new file mode 100644
index 0000000..c209db5
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs
@@ -0,0 +1,44 @@
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+/// <summary>
+/// Filters and sorts the arrivals based on the feed and the requested limit.
+/// This should run after real-time matching but before heavy enrichment (shapes, marquee).
+/// </summary>
+public class FilterAndSortProcessor : IArrivalsProcessor
+{
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ // 1. Sort by minutes
+ var sorted = context.Arrivals
+ .OrderBy(a => a.Estimate.Minutes)
+ .ToList();
+
+ // 2. Filter based on feed rules
+ var filtered = sorted.Where(a =>
+ {
+ if (feedId == "vitrasa")
+ {
+ // For Vitrasa, we hide past arrivals because we have real-time
+ // If a past arrival was matched to a real-time estimate, its Minutes will be >= 0
+ return a.Estimate.Minutes >= 0;
+ }
+
+ // For others, show up to 10 minutes ago
+ return a.Estimate.Minutes >= -10;
+ }).ToList();
+
+ // 3. Limit results
+ var limit = context.IsReduced ? 4 : 10;
+ var limited = filtered.Take(limit).ToList();
+
+ // Update the context list in-place
+ context.Arrivals.Clear();
+ context.Arrivals.AddRange(limited);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
new file mode 100644
index 0000000..ec65493
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
@@ -0,0 +1,26 @@
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class MarqueeProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public MarqueeProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (string.IsNullOrEmpty(arrival.Headsign.Marquee))
+ {
+ arrival.Headsign.Marquee = _feedService.GenerateMarquee(feedId, arrival.NextStops);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs
new file mode 100644
index 0000000..6273e0d
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs
@@ -0,0 +1,34 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class NextStopsProcessor : IArrivalsProcessor
+{
+ private readonly FeedService _feedService;
+
+ public NextStopsProcessor(FeedService feedService)
+ {
+ _feedService = feedService;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ var feedId = context.StopId.Split(':')[0];
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue;
+
+ // Filter stoptimes that are after the current stop's departure
+ var currentStopDeparture = otpArrival.ScheduledDepartureSeconds;
+
+ arrival.NextStops = otpArrival.Trip.Stoptimes
+ .Where(s => s.ScheduledDeparture > currentStopDeparture)
+ .OrderBy(s => s.ScheduledDeparture)
+ .Select(s => _feedService.NormalizeStopName(feedId, s.Stop.Name))
+ .ToList();
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs
new file mode 100644
index 0000000..300ce70
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs
@@ -0,0 +1,97 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class ShapeProcessor : IArrivalsProcessor
+{
+ private readonly ILogger<ShapeProcessor> _logger;
+
+ public ShapeProcessor(ILogger<ShapeProcessor> logger)
+ {
+ _logger = logger;
+ }
+
+ public Task ProcessAsync(ArrivalsContext context)
+ {
+ if (context.IsReduced)
+ {
+ return Task.CompletedTask;
+ }
+
+ foreach (var arrival in context.Arrivals)
+ {
+ if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue;
+
+ var encodedPoints = otpArrival.Trip.Geometry?.Points;
+ if (string.IsNullOrEmpty(encodedPoints))
+ {
+ _logger.LogDebug("No geometry found for trip {TripId}", arrival.TripId);
+ continue;
+ }
+
+ try
+ {
+ var points = Decode(encodedPoints);
+ if (points.Count == 0) continue;
+
+ arrival.Shape = new
+ {
+ type = "Feature",
+ geometry = new
+ {
+ type = "LineString",
+ coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList()
+ }
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error decoding shape for trip {TripId}", arrival.TripId);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static List<(double Lat, double Lon)> Decode(string encodedPoints)
+ {
+ var poly = new List<(double, double)>();
+ char[] polylineChars = encodedPoints.ToCharArray();
+ int index = 0;
+
+ int currentLat = 0;
+ int currentLng = 0;
+ int next5bits;
+ int sum;
+ int shifter;
+
+ while (index < polylineChars.Length)
+ {
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0));
+ }
+
+ return poly;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
new file mode 100644
index 0000000..7c98cfb
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
@@ -0,0 +1,158 @@
+using Costasdev.Busurbano.Backend.GraphClient.App;
+using Costasdev.Busurbano.Backend.Types.Arrivals;
+using Costasdev.VigoTransitApi;
+
+namespace Costasdev.Busurbano.Backend.Services.Processors;
+
+public class VitrasaRealTimeProcessor : IArrivalsProcessor
+{
+ private readonly VigoTransitApiClient _api;
+ private readonly FeedService _feedService;
+ private readonly ILogger<VitrasaRealTimeProcessor> _logger;
+
+ public VitrasaRealTimeProcessor(HttpClient http, FeedService feedService, ILogger<VitrasaRealTimeProcessor> logger)
+ {
+ _api = new VigoTransitApiClient(http);
+ _feedService = feedService;
+ _logger = logger;
+ }
+
+ public async Task ProcessAsync(ArrivalsContext context)
+ {
+ if (!context.StopId.StartsWith("vitrasa:")) return;
+
+ var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode);
+ if (!int.TryParse(normalizedCode, out var numericStopId)) return;
+
+ try
+ {
+ var realtime = await _api.GetStopEstimates(numericStopId);
+ var estimates = realtime.Estimates
+ .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*'))
+ .ToList();
+
+ var usedTripIds = new HashSet<string>();
+ var newArrivals = new List<Arrival>();
+
+ foreach (var estimate in estimates)
+ {
+ var estimateRouteNormalized = _feedService.NormalizeRouteNameForMatching(estimate.Route);
+
+ var bestMatch = context.Arrivals
+ .Where(a => !usedTripIds.Contains(a.TripId))
+ .Where(a => a.Route.ShortName.Trim() == estimate.Line.Trim())
+ .Select(a =>
+ {
+ var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(a.Headsign.Destination);
+ string? arrivalLongNameNormalized = null;
+ string? arrivalLastStopNormalized = null;
+
+ if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival)
+ {
+ if (otpArrival.Trip.Route.LongName != null)
+ {
+ arrivalLongNameNormalized = _feedService.NormalizeRouteNameForMatching(otpArrival.Trip.Route.LongName);
+ }
+
+ var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault();
+ if (lastStop != null)
+ {
+ arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name);
+ }
+ }
+
+ // Strict route matching logic ported from VitrasaTransitProvider
+ // Check against Headsign, LongName, and LastStop
+ var routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalRouteNormalized);
+
+ if (!routeMatch && arrivalLongNameNormalized != null)
+ {
+ routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLongNameNormalized);
+ }
+
+ if (!routeMatch && arrivalLastStopNormalized != null)
+ {
+ routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLastStopNormalized);
+ }
+
+ return new
+ {
+ Arrival = a,
+ TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule
+ RouteMatch = routeMatch
+ };
+ })
+ .Where(x => x.RouteMatch) // Strict route matching
+ .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule)
+ .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit
+ .FirstOrDefault();
+
+ if (bestMatch != null)
+ {
+ var arrival = bestMatch.Arrival;
+ _logger.LogInformation("Matched Vitrasa real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)",
+ arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff);
+
+ var scheduledMinutes = arrival.Estimate.Minutes;
+ arrival.Estimate.Minutes = estimate.Minutes;
+ arrival.Estimate.Precision = ArrivalPrecision.Confident;
+
+ // Calculate delay badge
+ var delayMinutes = estimate.Minutes - scheduledMinutes;
+ if (delayMinutes != 0)
+ {
+ arrival.Delay = new DelayBadge { Minutes = delayMinutes };
+ }
+
+ // Prefer real-time headsign if available and different
+ if (!string.IsNullOrWhiteSpace(estimate.Route))
+ {
+ arrival.Headsign.Destination = estimate.Route;
+ }
+
+ usedTripIds.Add(arrival.TripId);
+ }
+ else
+ {
+ _logger.LogInformation("Adding unmatched Vitrasa real-time arrival for line {Line} in {Minutes}m",
+ estimate.Line, estimate.Minutes);
+
+ // Try to find a "template" arrival with the same line to copy colors from
+ var template = context.Arrivals
+ .FirstOrDefault(a => a.Route.ShortName.Trim() == estimate.Line.Trim());
+
+ newArrivals.Add(new Arrival
+ {
+ TripId = $"vitrasa:rt:{estimate.Line}:{estimate.Route}:{estimate.Minutes}",
+ Route = new RouteInfo
+ {
+ ShortName = estimate.Line,
+ Colour = template?.Route.Colour ?? "FFFFFF",
+ TextColour = template?.Route.TextColour ?? "000000"
+ },
+ Headsign = new HeadsignInfo
+ {
+ Destination = estimate.Route
+ },
+ Estimate = new ArrivalDetails
+ {
+ Minutes = estimate.Minutes,
+ Precision = ArrivalPrecision.Confident
+ }
+ });
+ }
+ }
+
+ context.Arrivals.AddRange(newArrivals);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId);
+ }
+ }
+
+ private static bool IsRouteMatch(string a, string b)
+ {
+ return a == b || a.Contains(b) || b.Contains(a);
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs
index 516a1c5..65ef606 100644
--- a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs
+++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs
@@ -21,6 +21,15 @@ public class Arrival
[JsonPropertyName("shift")]
public ShiftBadge? Shift { get; set; }
+
+ [JsonPropertyName("shape")]
+ public object? Shape { get; set; }
+
+ [JsonIgnore]
+ public List<string> NextStops { get; set; } = [];
+
+ [JsonIgnore]
+ public object? RawOtpTrip { get; set; }
}
public class RouteInfo
@@ -78,8 +87,8 @@ public class DelayBadge
public class ShiftBadge
{
[JsonPropertyName("shiftName")]
- public string ShiftName { get; set; }
+ public required string ShiftName { get; set; }
[JsonPropertyName("shiftTrip")]
- public string ShiftTrip { get; set; }
+ public required string ShiftTrip { get; set; }
}
diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx
index de4fcc7..5cfbaa3 100644
--- a/src/frontend/app/components/arrivals/ArrivalCard.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx
@@ -1,3 +1,4 @@
+import { AlertTriangle, LocateIcon } from "lucide-react";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LineIcon from "~/components/LineIcon";
@@ -6,15 +7,11 @@ import "./ArrivalCard.css";
interface ArrivalCardProps {
arrival: Arrival;
- reduced?: boolean;
}
-export const ArrivalCard: React.FC<ArrivalCardProps> = ({
- arrival,
- reduced,
-}) => {
+export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
const { t } = useTranslation();
- const { route, headsign, estimate } = arrival;
+ const { route, headsign, estimate, delay, shift } = arrival;
const etaValue = estimate.minutes.toString();
const etaUnit = t("estimates.minutes", "min");
@@ -32,15 +29,73 @@ export const ArrivalCard: React.FC<ArrivalCardProps> = ({
}
}, [estimate.precision]);
+ const metaChips = useMemo(() => {
+ const chips: Array<{
+ label: string;
+ tone?: string;
+ kind?: "regular" | "gps" | "delay" | "warning";
+ }> = [];
+
+ // Delay chip
+ if (delay) {
+ const delta = Math.round(delay.minutes);
+ const absDelta = Math.abs(delta);
+
+ if (delta === 0) {
+ chips.push({
+ label: "OK",
+ tone: "delay-ok",
+ kind: "delay",
+ });
+ } else if (delta > 0) {
+ const tone =
+ delta <= 2
+ ? "delay-ok"
+ : delta <= 10
+ ? "delay-warn"
+ : "delay-critical";
+ chips.push({
+ label: `R${delta}`,
+ tone,
+ kind: "delay",
+ });
+ } else {
+ const tone = absDelta <= 2 ? "delay-ok" : "delay-early";
+ chips.push({
+ label: `A${absDelta}`,
+ tone,
+ kind: "delay",
+ });
+ }
+ }
+
+ // Shift chip
+ if (shift) {
+ chips.push({
+ label: `${shift.shiftName} · ${shift.shiftTrip}`,
+ kind: "regular",
+ });
+ }
+
+ // Precision chips
+ if (estimate.precision === "unsure") {
+ chips.push({
+ label: "!",
+ tone: "warning",
+ kind: "warning",
+ });
+ } else if (estimate.precision === "confident") {
+ chips.push({
+ label: "", // Just the icon for reduced
+ kind: "gps",
+ });
+ }
+
+ return chips;
+ }, [delay, shift, estimate.precision]);
+
return (
- <div
- className={`
- flex-none flex items-center gap-2.5 min-h-12
- bg-(--message-background-color) border border-(--border-color)
- rounded-xl px-3 py-2.5 transition-all
- ${reduced ? "reduced" : ""}
- `.trim()}
- >
+ <div className="flex-none flex items-center gap-2.5 min-h-12 rounded px-3 py-2.5 transition-all bg-slate-50 dark:bg-slate-800 shadow-sm">
<div className="shrink-0 min-w-[7ch]">
<LineIcon
line={route.shortName}
@@ -50,11 +105,58 @@ export const ArrivalCard: React.FC<ArrivalCardProps> = ({
/>
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
- <strong
- className={`text-base overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through" : ""}`}
+ <span
+ className={`text-base font-medium overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through" : ""}`}
>
{headsign.destination}
- </strong>
+ </span>
+ {metaChips.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap">
+ {metaChips.map((chip, idx) => {
+ let chipColourClasses = "";
+ switch (chip.tone) {
+ case "delay-ok":
+ chipColourClasses =
+ "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300";
+ break;
+ case "delay-warn":
+ chipColourClasses =
+ "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300";
+ break;
+ case "delay-critical":
+ chipColourClasses =
+ "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300";
+ break;
+ case "delay-early":
+ chipColourClasses =
+ "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300";
+ break;
+ case "warning":
+ chipColourClasses =
+ "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300";
+ break;
+ default:
+ chipColourClasses =
+ "bg-black/[0.06] dark:bg-white/[0.12] text-slate-600 dark:text-slate-400";
+ }
+
+ return (
+ <span
+ key={`${chip.label}-${idx}`}
+ className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
+ >
+ {chip.kind === "gps" && (
+ <LocateIcon className="w-3 h-3 my-0.5 inline-block" />
+ )}
+ {chip.kind === "warning" && (
+ <AlertTriangle className="w-3 h-3 my-0.5 inline-block" />
+ )}
+ {chip.label}
+ </span>
+ );
+ })}
+ </div>
+ )}
</div>
<div
className={`
diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx
index a1210d5..b2394fb 100644
--- a/src/frontend/app/components/arrivals/ArrivalList.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalList.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { type Arrival } from "../../api/schema";
-import { ArrivalCard } from "./ArrivalCard";
+import { ReducedArrivalCard } from "./ArrivalCard";
interface ArrivalListProps {
arrivals: Arrival[];
@@ -14,7 +14,7 @@ export const ArrivalList: React.FC<ArrivalListProps> = ({
return (
<div className="flex flex-col gap-3">
{arrivals.map((arrival, index) => (
- <ArrivalCard
+ <ReducedArrivalCard
key={`${arrival.route.shortName}-${index}`}
arrival={arrival}
reduced={reduced}
diff --git a/src/frontend/app/components/map/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx
index e318bee..56e80a4 100644
--- a/src/frontend/app/components/map/StopSummarySheet.tsx
+++ b/src/frontend/app/components/map/StopSummarySheet.tsx
@@ -56,7 +56,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
<span className="stop-sheet-id">({stop.stopCode})</span>
</div>
- <div className={`d-flex flex-wrap flex-row gap-2`}>
+ <div className={`flex flex-wrap flex-row gap-2`}>
{stop.lines.map((lineObj) => (
<LineIcon
key={lineObj.line}
diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts
index 9a0fdd1..38ebb0b 100644
--- a/src/frontend/app/config/constants.ts
+++ b/src/frontend/app/config/constants.ts
@@ -13,8 +13,8 @@ export const APP_CONSTANTS = {
lng: -8.72246955783102,
} as LngLatLike,
bounds: {
- sw: [-9.629517, 41.463312] as LngLatLike,
- ne: [-6.289673, 43.711564] as LngLatLike,
+ sw: [-16, 36] as LngLatLike,
+ ne: [2, 45.5] as LngLatLike,
},
textColour: "#e72b37",
defaultZoom: 14,