aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-01-02 01:08:41 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-01-02 01:08:41 +0100
commita3eb2d0441ae18f75604a4bee64db18391469837 (patch)
tree8994c1987afdd9436ba0699236439d3eb6c3f04d /src/Enmarcha.Backend
parentdd544d713a2af4713c61ae0d2050f2861cc0892a (diff)
feat: Integrate Geoapify geocoding service and update configuration
Diffstat (limited to 'src/Enmarcha.Backend')
-rw-r--r--src/Enmarcha.Backend/Configuration/AppConfiguration.cs1
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs7
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs13
-rw-r--r--src/Enmarcha.Backend/Controllers/TransitController.cs1
-rw-r--r--src/Enmarcha.Backend/Controllers/VigoController.cs69
-rw-r--r--src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs1
-rw-r--r--src/Enmarcha.Backend/Program.cs7
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs110
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs (renamed from src/Enmarcha.Backend/Services/IGeocodingService.cs)2
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs (renamed from src/Enmarcha.Backend/Services/NominatimGeocodingService.cs)4
-rw-r--r--src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs1
-rw-r--r--src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs2
-rw-r--r--src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs3
-rw-r--r--src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs8
-rw-r--r--src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs64
-rw-r--r--src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs281
-rw-r--r--src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs2
-rw-r--r--src/Enmarcha.Backend/Services/ShapeTraversalService.cs1
-rw-r--r--src/Enmarcha.Backend/Types/Arrivals/Arrival.cs4
-rw-r--r--src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs73
20 files changed, 200 insertions, 454 deletions
diff --git a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
index e86ac39..ca99e3c 100644
--- a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
+++ b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
@@ -3,5 +3,6 @@ namespace Enmarcha.Backend.Configuration;
public class AppConfiguration
{
public required string OpenTripPlannerBaseUrl { get; set; }
+ public required string GeoapifyApiKey { get; set; }
public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org";
}
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index 7260fb4..a23c69c 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -1,4 +1,4 @@
-using System.Net;
+using System.Net;
using Enmarcha.Sources.OpenTripPlannerGql;
using Enmarcha.Sources.OpenTripPlannerGql.Queries;
using Enmarcha.Backend.Configuration;
@@ -87,11 +87,6 @@ public partial class ArrivalsController : ControllerBase
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);
diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
index 7a03a24..89f6c59 100644
--- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
+++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
@@ -3,6 +3,7 @@ using Enmarcha.Sources.OpenTripPlannerGql;
using Enmarcha.Sources.OpenTripPlannerGql.Queries;
using Enmarcha.Backend.Configuration;
using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Services.Geocoding;
using Enmarcha.Backend.Types.Planner;
using FuzzySharp;
using Microsoft.AspNetCore.Mvc;
@@ -57,7 +58,7 @@ public partial class RoutePlannerController : ControllerBase
await Task.WhenAll(nominatimTask, stopsTask);
- var nominatimResults = await nominatimTask;
+ var geocodingResults = await nominatimTask;
var allStops = await stopsTask;
// Fuzzy search stops
@@ -65,14 +66,14 @@ public partial class RoutePlannerController : ControllerBase
query,
allStops.Select(s => s.Name ?? string.Empty),
cutoff: 60
- ).Take(5).Select(r => allStops[r.Index]).ToList();
+ ).Take(4).Select(r => allStops[r.Index]).ToList();
- // Merge results: stops first, then nominatim, deduplicating by coordinates (approx)
- var finalResults = new List<PlannerSearchResult>(fuzzyResults);
+ // Merge results: geocoding first, then stops, deduplicating by coordinates (approx)
+ var finalResults = new List<PlannerSearchResult>(geocodingResults);
- foreach (var res in nominatimResults)
+ foreach (var res in fuzzyResults)
{
- if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.0001 && Math.Abs(f.Lon - res.Lon) < 0.0001))
+ if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.00001 && Math.Abs(f.Lon - res.Lon) < 0.00001))
{
finalResults.Add(res);
}
diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs
index 62b3725..00e5fb7 100644
--- a/src/Enmarcha.Backend/Controllers/TransitController.cs
+++ b/src/Enmarcha.Backend/Controllers/TransitController.cs
@@ -62,7 +62,6 @@ public class TransitController : ControllerBase
var routes = response.Data.Routes
.Select(_otpService.MapRoute)
- .Where(r => r.TripCount > 0)
.OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
.ToList();
diff --git a/src/Enmarcha.Backend/Controllers/VigoController.cs b/src/Enmarcha.Backend/Controllers/VigoController.cs
deleted file mode 100644
index 4199251..0000000
--- a/src/Enmarcha.Backend/Controllers/VigoController.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using Costasdev.VigoTransitApi;
-using Enmarcha.Backend.Configuration;
-using Enmarcha.Backend.Services;
-using Enmarcha.Backend.Services.Providers;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
-
-namespace Enmarcha.Backend.Controllers;
-
-[ApiController]
-[Route("api/vigo")]
-public partial class VigoController : ControllerBase
-{
- private readonly ILogger<VigoController> _logger;
- private readonly VigoTransitApiClient _api;
- private readonly AppConfiguration _configuration;
- private readonly ShapeTraversalService _shapeService;
- private readonly VitrasaTransitProvider _vitrasaProvider;
- private readonly RenfeTransitProvider _renfeProvider;
-
- public VigoController(
- HttpClient http,
- IOptions<AppConfiguration> options,
- ILogger<VigoController> logger,
- ShapeTraversalService shapeService,
- VitrasaTransitProvider vitrasaProvider,
- RenfeTransitProvider renfeProvider)
- {
- _logger = logger;
- _api = new VigoTransitApiClient(http);
- _configuration = options.Value;
- _shapeService = shapeService;
- _vitrasaProvider = vitrasaProvider;
- _renfeProvider = renfeProvider;
- }
-
- [HttpGet("GetConsolidatedCirculations")]
- public async Task<IActionResult> GetConsolidatedCirculations(
- [FromQuery] string stopId
- )
- {
- // Use Europe/Madrid timezone consistently to avoid UTC/local skew
- var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
- var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz);
-
- ITransitProvider provider;
- string effectiveStopId;
-
- if (stopId.StartsWith("renfe:"))
- {
- provider = _renfeProvider;
- effectiveStopId = stopId.Substring("renfe:".Length);
- }
- else if (stopId.StartsWith("vitrasa:"))
- {
- provider = _vitrasaProvider;
- effectiveStopId = stopId.Substring("vitrasa:".Length);
- }
- else
- {
- // Legacy/Default
- provider = _vitrasaProvider;
- effectiveStopId = stopId;
- }
-
- var result = await provider.GetCirculationsAsync(effectiveStopId, nowLocal);
- return Ok(result);
- }
-}
diff --git a/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs b/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs
index bbf0e4b..1ef7990 100644
--- a/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs
+++ b/src/Enmarcha.Backend/Extensions/StopScheduleExtensions.cs
@@ -1,5 +1,4 @@
using Enmarcha.Backend.Types;
-using static Enmarcha.Backend.Types.StopArrivals.Types;
namespace Enmarcha.Backend.Extensions;
diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs
index 4450da6..a13abec 100644
--- a/src/Enmarcha.Backend/Program.cs
+++ b/src/Enmarcha.Backend/Program.cs
@@ -1,9 +1,9 @@
using System.Text.Json.Serialization;
using Enmarcha.Backend.Configuration;
using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Services.Geocoding;
using Enmarcha.Backend.Services.Processors;
using Enmarcha.Backend.Services.Providers;
-using Enmarcha.Sources.TranviasCoruna;
var builder = WebApplication.CreateBuilder(args);
@@ -37,10 +37,9 @@ builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, FeedConfigProcessor>();
builder.Services.AddScoped<ArrivalsPipeline>();
-builder.Services.AddHttpClient<IGeocodingService, NominatimGeocodingService>();
+// builder.Services.AddKeyedScoped<IGeocodingService, NominatimGeocodingService>("Nominatim");
+builder.Services.AddHttpClient<IGeocodingService, GeoapifyGeocodingService>();
builder.Services.AddHttpClient<OtpService>();
-builder.Services.AddScoped<VitrasaTransitProvider>();
-builder.Services.AddScoped<RenfeTransitProvider>();
var app = builder.Build();
diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
new file mode 100644
index 0000000..d6cf5f6
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs
@@ -0,0 +1,110 @@
+using System.Globalization;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types.Geoapify;
+using Enmarcha.Backend.Types.Planner;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services.Geocoding;
+
+public class GeoapifyGeocodingService : IGeocodingService
+{
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<GeoapifyGeocodingService> _logger;
+ private readonly AppConfiguration _config;
+
+ private static readonly string[] ForbiddenResultTypes = ["city", "state", "county", "postcode"];
+
+ public GeoapifyGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<GeoapifyGeocodingService> logger, IOptions<AppConfiguration> config)
+ {
+ _httpClient = httpClient;
+ _cache = cache;
+ _logger = logger;
+ _config = config.Value;
+
+ // Geoapify requires a User-Agent
+ if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
+ {
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)");
+ }
+ }
+
+ public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return [];
+ }
+
+ var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null)
+ {
+ return cachedResults;
+ }
+
+ var url = $"https://api.geoapify.com/v1/geocode/autocomplete?text={Uri.EscapeDataString(query)}&lang=gl&limit=5&filter=rect:-9.449497230816405,41.89720361654395,-6.581039728137625,43.92616367306067&format=json";
+
+ try
+ {
+ var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}");
+
+
+ var results = response?.results
+ .Where(x => !ForbiddenResultTypes.Contains(x.result_type))
+ .Select(MapToPlannerSearchResult)
+ .ToList() ?? [];
+
+ _cache.Set(cacheKey, results, TimeSpan.FromMinutes(60));
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url);
+ return new List<PlannerSearchResult>();
+ }
+ }
+
+ public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
+ {
+ var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}";
+ if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null)
+ {
+ return cachedResult;
+ }
+
+ var url =
+ $"https://api.geoapify.com/v1/geocode/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=gl&format=json";
+ try
+ {
+ var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}");
+
+ if (response == null) return null;
+
+ var result = MapToPlannerSearchResult(response.results[0]);
+
+ _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60));
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Geoapify reverse geocode results from {Url}", url);
+ return null;
+ }
+ }
+
+ private PlannerSearchResult MapToPlannerSearchResult(Result result)
+ {
+ var name = result.name ?? result.address_line1;
+ var label = $"{result.street} ({result.postcode} {result.city}, {result.county})";
+
+ return new PlannerSearchResult
+ {
+ Name = name,
+ Label = label,
+ Lat = result.lat,
+ Lon = result.lon,
+ Layer = result.result_type
+ };
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/IGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs
index 5c1b19e..8619b0a 100644
--- a/src/Enmarcha.Backend/Services/IGeocodingService.cs
+++ b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs
@@ -1,6 +1,6 @@
using Enmarcha.Backend.Types.Planner;
-namespace Enmarcha.Backend.Services;
+namespace Enmarcha.Backend.Services.Geocoding;
public interface IGeocodingService
{
diff --git a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs
index e8eccb5..c38b1e6 100644
--- a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs
+++ b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs
@@ -5,7 +5,7 @@ using Enmarcha.Backend.Types.Planner;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
-namespace Enmarcha.Backend.Services;
+namespace Enmarcha.Backend.Services.Geocoding;
public class NominatimGeocodingService : IGeocodingService
{
@@ -26,7 +26,7 @@ public class NominatimGeocodingService : IGeocodingService
// Nominatim requires a User-Agent
if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
{
- _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app)");
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)");
}
}
diff --git a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs
index ad5465f..6a9d9dc 100644
--- a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs
+++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs
@@ -2,7 +2,6 @@ using Enmarcha.Sources.OpenTripPlannerGql.Queries;
using Enmarcha.Sources.TranviasCoruna;
using Enmarcha.Backend.Types;
using Enmarcha.Backend.Types.Arrivals;
-using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival;
namespace Enmarcha.Backend.Services.Processors;
diff --git a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs
index 7df00fa..18ba2ac 100644
--- a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs
+++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs
@@ -1,5 +1,3 @@
-using Enmarcha.Backend.Types.Arrivals;
-
namespace Enmarcha.Backend.Services.Processors;
/// <summary>
diff --git a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs
index d14cfa0..929e439 100644
--- a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs
+++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs
@@ -1,7 +1,4 @@
using Enmarcha.Backend.Helpers;
-using Enmarcha.Sources.OpenTripPlannerGql.Queries;
-using Enmarcha.Sources.TranviasCoruna;
-using Enmarcha.Backend.Types;
using Enmarcha.Backend.Types.Arrivals;
using Enmarcha.Sources.Tussa;
using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival;
diff --git a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs
deleted file mode 100644
index 77f6341..0000000
--- a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Enmarcha.Backend.Types;
-
-namespace Enmarcha.Backend.Services.Providers;
-
-public interface ITransitProvider
-{
- Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime now);
-}
diff --git a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs
deleted file mode 100644
index 036c9b1..0000000
--- a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Enmarcha.Backend.Extensions;
-using Enmarcha.Backend.Configuration;
-using Enmarcha.Backend.Types;
-using Microsoft.Extensions.Options;
-using SysFile = System.IO.File;
-
-namespace Enmarcha.Backend.Services.Providers;
-
-[Obsolete]
-public class RenfeTransitProvider : ITransitProvider
-{
- private readonly AppConfiguration _configuration;
- private readonly ILogger<RenfeTransitProvider> _logger;
-
- public RenfeTransitProvider(IOptions<AppConfiguration> options, ILogger<RenfeTransitProvider> logger)
- {
- _configuration = options.Value;
- _logger = logger;
- }
-
- public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal)
- {
- var todayDate = nowLocal.Date.ToString("yyyy-MM-dd");
- StopArrivals stopArrivals = null!;
-
- if (stopArrivals == null)
- {
- return [];
- }
-
- var now = nowLocal.AddSeconds(60 - nowLocal.Second);
- var scopeEnd = now.AddMinutes(8 * 60);
-
- var scheduledWindow = stopArrivals.Arrivals
- .Where(c => c.CallingDateTime(nowLocal.Date) != null)
- .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd)
- .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value);
-
- var consolidatedCirculations = new List<ConsolidatedCirculation>();
-
- foreach (var sched in scheduledWindow)
- {
- var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes;
-
- consolidatedCirculations.Add(new ConsolidatedCirculation
- {
- Line = sched.Line,
- Route = sched.Route,
- Schedule = new ScheduleData
- {
- Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now,
- Minutes = minutes,
- TripId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)],
- ServiceId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)],
- ShapeId = sched.ShapeId,
- },
- RealTime = null,
- NextStreets = [.. sched.NextStreets]
- });
- }
-
- return consolidatedCirculations;
- }
-}
diff --git a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs
deleted file mode 100644
index 8a05fc6..0000000
--- a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs
+++ /dev/null
@@ -1,281 +0,0 @@
-using System.Globalization;
-using System.Text;
-using Enmarcha.Backend.Extensions;
-using Costasdev.VigoTransitApi;
-using Enmarcha.Backend.Configuration;
-using Enmarcha.Backend.Types;
-using Microsoft.Extensions.Options;
-using static Enmarcha.Backend.Types.StopArrivals.Types;
-using SysFile = System.IO.File;
-
-namespace Enmarcha.Backend.Services.Providers;
-
-[Obsolete]
-public class VitrasaTransitProvider : ITransitProvider
-{
- private readonly VigoTransitApiClient _api;
- private readonly AppConfiguration _configuration;
- private readonly ShapeTraversalService _shapeService;
- private readonly LineFormatterService _lineFormatter;
- private readonly ILogger<VitrasaTransitProvider> _logger;
-
- public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger<VitrasaTransitProvider> logger)
- {
- _api = new VigoTransitApiClient(http);
- _configuration = options.Value;
- _shapeService = shapeService;
- _lineFormatter = lineFormatter;
- _logger = logger;
- }
-
- public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal)
- {
- // Vitrasa stop IDs are integers, but we receive string "vitrasa:1234" or just "1234" if legacy
- // The caller (Controller) should probably strip the prefix, but let's handle it here just in case or assume it's stripped.
- // The user said: "Routing the request to one or tthe other will just work with the prefix. For example calling `/api/GetConsolidatedCirculations?stopId=vitrasa:1400` will call the vitrasa driver with stop 1400."
- // So I should expect the ID part only here? Or the full ID?
- // Usually providers take the ID they understand. I'll assume the controller strips the prefix.
-
- if (!int.TryParse(stopId, out var numericStopId))
- {
- _logger.LogError("Invalid Vitrasa stop ID: {StopId}", stopId);
- return [];
- }
-
- var realtimeTask = _api.GetStopEstimates(numericStopId);
- var todayDate = nowLocal.Date.ToString("yyyy-MM-dd");
-
- // Load both today's and tomorrow's schedules to handle night services
- var timetableTask = LoadStopArrivalsProto(stopId, todayDate);
-
- // Wait for real-time data and today's schedule (required)
- await Task.WhenAll(realtimeTask, timetableTask);
-
- var realTimeEstimates = realtimeTask.Result.Estimates
- .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*'))
- .ToList();
-
- // Handle case where schedule file doesn't exist - return realtime-only data
- if (timetableTask.Result == null)
- {
- _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate);
-
- var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation
- {
- Line = estimate.Line,
- Route = estimate.Route,
- Schedule = null,
- RealTime = new RealTimeData
- {
- Minutes = estimate.Minutes,
- Distance = estimate.Meters
- }
- }).OrderBy(c => c.RealTime!.Minutes).ToList();
-
- return realtimeOnlyCirculations;
- }
-
- var timetable = timetableTask.Result.Arrivals
- .Where(c => c.StartingDateTime(nowLocal.Date) != null && c.CallingDateTime(nowLocal.Date) != null)
- .ToList();
-
- var stopLocation = timetableTask.Result.Location;
-
- var now = nowLocal.AddSeconds(60 - nowLocal.Second);
- // Define the scope end as the time of the last realtime arrival (no extra buffer)
- var scopeEnd = realTimeEstimates.Count > 0
- ? now.AddMinutes(Math.Min(realTimeEstimates.Max(e => e.Minutes) + 5, 75))
- : now.AddMinutes(60); // If no estimates, show next hour of scheduled only
-
- List<ConsolidatedCirculation> consolidatedCirculations = [];
- var usedTripIds = new HashSet<string>();
-
- foreach (var estimate in realTimeEstimates)
- {
- var estimatedArrivalTime = now.AddMinutes(estimate.Minutes);
-
- var possibleCirculations = timetable
- .Where(c =>
- {
- // Match by line number
- if (c.Line.Trim() != estimate.Line.Trim())
- return false;
-
- // Match by route (destination) - compare with both Route field and Terminus stop name
- // Normalize both sides: remove non-ASCII-alnum characters and lowercase
- var estimateRoute = NormalizeRouteName(estimate.Route);
- var scheduleRoute = NormalizeRouteName(c.Route);
- var scheduleTerminus = NormalizeRouteName(c.TerminusName);
-
- // TODO: Replace ñapa with fuzzy matching or better logic
- return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute ||
- scheduleRoute.Contains(estimateRoute) || estimateRoute.Contains(scheduleRoute);
- })
- .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value)
- .ToArray();
-
- StopArrivals.Types.ScheduledArrival? closestCirculation = null;
-
- const int maxEarlyArrivalMinutes = 7;
-
- var bestMatch = possibleCirculations
- .Select(c => new
- {
- Circulation = c,
- TimeDiff = (c.CallingDateTime(nowLocal.Date)!.Value - estimatedArrivalTime).TotalMinutes
- })
- .Where(x => x.TimeDiff <= maxEarlyArrivalMinutes && x.TimeDiff >= -75)
- .OrderBy(x => Math.Abs(x.TimeDiff))
- .FirstOrDefault();
-
- if (bestMatch != null)
- {
- closestCirculation = bestMatch.Circulation;
- }
-
- if (closestCirculation == null)
- {
- // No scheduled match: include realtime-only entry
- _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route));
- consolidatedCirculations.Add(new ConsolidatedCirculation
- {
- Line = estimate.Line,
- Route = estimate.Route,
- Schedule = null,
- RealTime = new RealTimeData
- {
- Minutes = estimate.Minutes,
- Distance = estimate.Meters
- }
- });
-
- continue;
- }
-
- // Ensure each scheduled trip is only matched once to a realtime estimate
- if (usedTripIds.Contains(closestCirculation.TripId))
- {
- _logger.LogInformation("Skipping duplicate realtime match for TripId {TripId}", closestCirculation.TripId);
- continue;
- }
-
- var isRunning = closestCirculation.StartingDateTime(nowLocal.Date)!.Value <= now;
- Position? currentPosition = null;
- int? stopShapeIndex = null;
- bool usePreviousShape = false;
-
- consolidatedCirculations.Add(new ConsolidatedCirculation
- {
- Line = estimate.Line,
- Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route,
- NextStreets = [.. closestCirculation.NextStreets],
- Schedule = new ScheduleData
- {
- Running = isRunning,
- Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes,
- TripId = closestCirculation.TripId,
- ServiceId = closestCirculation.ServiceId,
- ShapeId = closestCirculation.ShapeId,
- },
- RealTime = new RealTimeData
- {
- Minutes = estimate.Minutes,
- Distance = estimate.Meters
- },
- CurrentPosition = currentPosition,
- StopShapeIndex = stopShapeIndex,
- IsPreviousTrip = usePreviousShape,
- PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null
- });
-
- usedTripIds.Add(closestCirculation.TripId);
- }
-
- // Add scheduled-only circulations between now and the last realtime arrival
- if (scopeEnd > now)
- {
- var matchedTripIds = new HashSet<string>(usedTripIds);
-
- var scheduledWindow = timetable
- .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd)
- .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value);
-
- foreach (var sched in scheduledWindow)
- {
- if (matchedTripIds.Contains(sched.TripId))
- {
- continue; // already represented via a matched realtime
- }
-
- var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes;
- if (minutes == 0)
- {
- continue;
- }
-
- consolidatedCirculations.Add(new ConsolidatedCirculation
- {
- Line = sched.Line,
- Route = sched.Route,
- Schedule = new ScheduleData
- {
- Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now,
- Minutes = minutes,
- TripId = sched.TripId,
- ServiceId = sched.ServiceId,
- ShapeId = sched.ShapeId,
- },
- RealTime = null
- });
- }
- }
-
- // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes)
- var sorted = consolidatedCirculations
- .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes)
- .Select(_lineFormatter.Format)
- .ToList();
-
- return sorted;
- }
-
- private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString)
- {
- return new StopArrivals();
- // var file = Path.Combine(_configuration.VitrasaScheduleBasePath, dateString, stopId + ".pb");
- // if (!SysFile.Exists(file))
- // {
- // _logger.LogWarning("Stop arrivals proto file not found: {File}", file);
- // return null;
- // }
- //
- // var contents = await SysFile.ReadAllBytesAsync(file);
- // var stopArrivals = StopArrivals.Parser.ParseFrom(contents);
- // return stopArrivals;
- }
-
- private static string NormalizeRouteName(string route)
- {
- var normalized = route.Trim().ToLowerInvariant();
- // Remove diacritics/accents first, then filter to alphanumeric
- normalized = RemoveDiacritics(normalized);
- return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
- }
-
- private static string RemoveDiacritics(string text)
- {
- var normalizedString = text.Normalize(NormalizationForm.FormD);
- var stringBuilder = new StringBuilder();
-
- foreach (var c in normalizedString)
- {
- var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
- if (unicodeCategory != UnicodeCategory.NonSpacingMark)
- {
- stringBuilder.Append(c);
- }
- }
-
- return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
- }
-}
diff --git a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
index 4bb60e2..1780c09 100644
--- a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
+++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
@@ -1,4 +1,4 @@
-using System.Collections.Frozen;
+using System.Collections.Frozen;
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
diff --git a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs
index 221a975..1f77929 100644
--- a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs
+++ b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs
@@ -3,7 +3,6 @@ using Enmarcha.Backend.Types;
using Microsoft.Extensions.Options;
using ProjNet.CoordinateSystems;
using ProjNet.CoordinateSystems.Transformations;
-using SysFile = System.IO.File;
namespace Enmarcha.Backend.Services;
diff --git a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs
index 9d2ea1b..eab463d 100644
--- a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs
+++ b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs
@@ -1,6 +1,4 @@
-using System.Text.Json.Serialization;
-using Enmarcha.Backend.Types;
-using Newtonsoft.Json;
+using System.Text.Json.Serialization;
namespace Enmarcha.Backend.Types.Arrivals;
diff --git a/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs b/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs
new file mode 100644
index 0000000..ac692ca
--- /dev/null
+++ b/src/Enmarcha.Backend/Types/Geoapify/GeoapifyModels.cs
@@ -0,0 +1,73 @@
+using System.Text.Json.Serialization;
+
+namespace Enmarcha.Backend.Types.Geoapify;
+
+public class GeoapifyResult
+{
+ public Result[] results { get; set; }
+ public Query query { get; set; }
+}
+
+public class Result
+{
+ public string country_code { get; set; }
+ public string? name { get; set; }
+ public string street { get; set; }
+ public string country { get; set; }
+ public Datasource datasource { get; set; }
+ public string postcode { get; set; }
+ public string state { get; set; }
+ public string state_code { get; set; }
+ public string district { get; set; }
+ public string city { get; set; }
+ public string county { get; set; }
+ public string county_code { get; set; }
+ public double lon { get; set; }
+ public double lat { get; set; }
+ public string result_type { get; set; }
+ public string NUTS_3 { get; set; }
+ public string formatted { get; set; }
+ public string address_line1 { get; set; }
+ public string address_line2 { get; set; }
+ public Timezone timezone { get; set; }
+ public string plus_code { get; set; }
+ public string iso3166_2 { get; set; }
+ public string place_id { get; set; }
+ public Other_names other_names { get; set; }
+ public string suburb { get; set; }
+ public string housenumber { get; set; }
+ public string iso3166_2_sublevel { get; set; }
+ public string category { get; set; }
+}
+
+public class Datasource
+{
+ public string sourcename { get; set; }
+ public string attribution { get; set; }
+ public string license { get; set; }
+ public string url { get; set; }
+}
+
+public class Timezone
+{
+ public string name { get; set; }
+ public string offset_STD { get; set; }
+ public int offset_STD_seconds { get; set; }
+ public string offset_DST { get; set; }
+ public int offset_DST_seconds { get; set; }
+ public string abbreviation_STD { get; set; }
+ public string abbreviation_DST { get; set; }
+}
+
+public class Other_names
+{
+ public string name { get; set; }
+ public string name_gl { get; set; }
+ public string alt_name { get; set; }
+}
+
+public class Query
+{
+ public string text { get; set; }
+}
+