aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Controllers
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-01-25 21:35:36 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-01-25 21:35:36 +0100
commit6192730d1b7b0d08095d7da88caba73fd07fe99e (patch)
tree4a62610d8b8ee42c79380f2e4d3eb1480caccbf5 /src/Enmarcha.Backend/Controllers
parentf9b7af64550be1320acc84d60184e8c8ce873b94 (diff)
Bring back basic stop search
Diffstat (limited to 'src/Enmarcha.Backend/Controllers')
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs74
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs84
2 files changed, 109 insertions, 49 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index eb147fc..b93b3c9 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -6,6 +6,7 @@ using Enmarcha.Backend.Helpers;
using Enmarcha.Backend.Services;
using Enmarcha.Backend.Types;
using Enmarcha.Backend.Types.Arrivals;
+using FuzzySharp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
@@ -22,6 +23,7 @@ public partial class ArrivalsController : ControllerBase
private readonly ArrivalsPipeline _pipeline;
private readonly FeedService _feedService;
private readonly AppConfiguration _config;
+ private readonly OtpService _otpService;
public ArrivalsController(
ILogger<ArrivalsController> logger,
@@ -29,7 +31,8 @@ public partial class ArrivalsController : ControllerBase
HttpClient httpClient,
ArrivalsPipeline pipeline,
FeedService feedService,
- IOptions<AppConfiguration> configOptions
+ IOptions<AppConfiguration> configOptions,
+ OtpService otpService
)
{
_logger = logger;
@@ -38,6 +41,7 @@ public partial class ArrivalsController : ControllerBase
_pipeline = pipeline;
_feedService = feedService;
_config = configOptions.Value;
+ _otpService = otpService;
}
[HttpGet("arrivals")]
@@ -239,10 +243,72 @@ public partial class ArrivalsController : ControllerBase
}
[HttpGet("search")]
- public IActionResult SearchStops([FromQuery] string q)
+ public async Task<IActionResult> SearchStops([FromQuery] string q)
{
- // Placeholder for future implementation with Postgres and fuzzy searching
- return Ok(new List<object>());
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ return Ok(new List<object>());
+ }
+
+ const string cacheKey = "arrivals_search_mapped_stops";
+ if (!_cache.TryGetValue(cacheKey, out List<dynamic>? allStops) || allStops == null)
+ {
+ var allStopsRaw = await _otpService.GetStopsByBboxAsync(-9.3, 41.7, -6.7, 43.8);
+
+ allStops = allStopsRaw.Select(s =>
+ {
+ var feedId = s.GtfsId.Split(':', 2)[0];
+ var (fallbackColor, _) = _feedService.GetFallbackColourForFeed(feedId);
+ var code = _feedService.NormalizeStopCode(feedId, s.Code ?? "");
+ var name = _feedService.NormalizeStopName(feedId, s.Name);
+
+ return (dynamic)new
+ {
+ stopId = s.GtfsId,
+ stopCode = code,
+ name = name,
+ latitude = s.Lat,
+ longitude = s.Lon,
+ lines = s.Routes?
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .Select(r => new
+ {
+ line = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ colour = r.Color ?? fallbackColor,
+ textColour = r.TextColor is null or "000000" ?
+ ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
+ r.TextColor
+ })
+ .ToList() ?? [],
+ label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})"
+ };
+ }).ToList();
+
+ _cache.Set(cacheKey, allStops, TimeSpan.FromHours(1));
+ }
+
+ // 1. Exact or prefix matches by stop code
+ var codeMatches = allStops
+ .Where(s => s.stopCode != null && ((string)s.stopCode).StartsWith(q, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(s => ((string)s.stopCode).Length)
+ .Take(10)
+ .ToList();
+
+ // 2. Fuzzy search stops by label
+ var fuzzyResults = Process.ExtractSorted(
+ q,
+ allStops.Select(s => (string)s.label),
+ cutoff: 60
+ ).Take(15).Select(r => allStops[r.Index]).ToList();
+
+ // Combine and deduplicate
+ var results = codeMatches.Concat(fuzzyResults)
+ .GroupBy(s => s.stopId)
+ .Select(g => g.First())
+ .Take(20)
+ .ToList();
+
+ return Ok(results);
}
[LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")]
diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
index 89f6c59..3ffb02f 100644
--- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
+++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
@@ -61,17 +61,31 @@ public partial class RoutePlannerController : ControllerBase
var geocodingResults = await nominatimTask;
var allStops = await stopsTask;
- // Fuzzy search stops
+ // 1. Exact or prefix matches by stop code
+ var codeMatches = allStops
+ .Where(s => s.StopCode != null && s.StopCode.StartsWith(query, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(s => s.StopCode?.Length) // Shorter codes (more exact matches) first
+ .Take(5)
+ .ToList();
+
+ // 2. Fuzzy search stops by label (Name + Code)
var fuzzyResults = Process.ExtractSorted(
query,
- allStops.Select(s => s.Name ?? string.Empty),
+ allStops.Select(s => s.Label ?? string.Empty),
cutoff: 60
- ).Take(4).Select(r => allStops[r.Index]).ToList();
+ ).Take(6).Select(r => allStops[r.Index]).ToList();
+
+ // Merge stops, prioritizing code matches
+ var stopResults = codeMatches.Concat(fuzzyResults)
+ .GroupBy(s => s.StopId)
+ .Select(g => g.First())
+ .Take(6)
+ .ToList();
// Merge results: geocoding first, then stops, deduplicating by coordinates (approx)
var finalResults = new List<PlannerSearchResult>(geocodingResults);
- foreach (var res in fuzzyResults)
+ foreach (var res in stopResults)
{
if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.00001 && Math.Abs(f.Lon - res.Lon) < 0.00001))
{
@@ -138,53 +152,33 @@ public partial class RoutePlannerController : ControllerBase
private async Task<List<PlannerSearchResult>> GetCachedStopsAsync()
{
- const string cacheKey = "otp_all_stops";
- if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedStops) && cachedStops != null)
+ const string cacheKey = "planner_mapped_stops";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedStops))
{
- return cachedStops;
+ return cachedStops!;
}
- try
- {
- // Galicia bounds: minLon, minLat, maxLon, maxLat
- var bbox = new StopTileRequestContent.Bbox(-9.3, 41.7, -6.7, 43.8);
- var query = StopTileRequestContent.Query(bbox);
-
- var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
- request.Content = JsonContent.Create(new GraphClientRequest { Query = query });
-
- var response = await _httpClient.SendAsync(request);
- var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<StopTileResponse>>();
+ var allStopsRaw = await _otpService.GetStopsByBboxAsync(-9.3, 41.7, -6.7, 43.8);
- if (responseBody is not { IsSuccess: true } || responseBody.Data?.StopsByBbox == null)
- {
- _logger.LogError("Error fetching stops from OTP for caching");
- return new List<PlannerSearchResult>();
- }
+ var stops = allStopsRaw.Select(s =>
+ {
+ var feedId = s.GtfsId.Split(':')[0];
+ var name = _feedService.NormalizeStopName(feedId, s.Name);
+ var code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty);
- var stops = responseBody.Data.StopsByBbox.Select(s =>
+ return new PlannerSearchResult
{
- var feedId = s.GtfsId.Split(':')[0];
- var name = _feedService.NormalizeStopName(feedId, s.Name);
- var code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty);
-
- return new PlannerSearchResult
- {
- Name = name,
- Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})",
- Lat = s.Lat,
- Lon = s.Lon,
- Layer = "stop"
- };
- }).ToList();
+ Name = name,
+ Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})",
+ Lat = s.Lat,
+ Lon = s.Lon,
+ Layer = "stop",
+ StopId = s.GtfsId,
+ StopCode = code
+ };
+ }).ToList();
- _cache.Set(cacheKey, stops, TimeSpan.FromHours(18));
- return stops;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Exception fetching stops from OTP for caching");
- return new List<PlannerSearchResult>();
- }
+ _cache.Set(cacheKey, stops, TimeSpan.FromHours(1));
+ return stops;
}
}