diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-21 00:36:25 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-21 00:36:25 +0100 |
| commit | f1b0b5f7ceaf6d23ae347e12cf29eef617c7dc9b (patch) | |
| tree | d92c6075d019d87fd03482774050d7fc8981a5aa | |
| parent | 2ef155c0c68208eccf968919fea12133698b50a9 (diff) | |
feat: enhance geocoding and stop search with performance metrics and improved response handling
6 files changed, 101 insertions, 44 deletions
diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs index 489f264..426170d 100644 --- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -53,16 +53,45 @@ public partial class RoutePlannerController : ControllerBase return BadRequest("Query cannot be empty"); } - var nominatimTask = _geocodingService.GetAutocompleteAsync(query); - var stopsTask = GetCachedStopsAsync(); + DateTime startTime = DateTime.UtcNow; + var geocodingTask = _geocodingService.GetAutocompleteAsync(query).ContinueWith(t => + { + var duration = DateTime.UtcNow - startTime; + Response.Headers.Append("Server-Timing", $"geocoding;dur={(int)duration.TotalMilliseconds}"); + return t.Result; + }); + var stopTask = SearchStops(query).ContinueWith(t => + { + var duration = DateTime.UtcNow - startTime; + Response.Headers.Append("Server-Timing", $"stop_search;dur={(int)duration.TotalMilliseconds}"); + return t.Result; + }); + + await Task.WhenAll(geocodingTask, stopTask); + + var geocodingResults = await geocodingTask; + var stopResults = await stopTask; + + // Merge results: geocoding first, then stops, deduplicating by coordinates (approx) + var finalResults = new List<PlannerSearchResult>(geocodingResults); + + foreach (var res in stopResults) + { + if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.0001 && Math.Abs(f.Lon - res.Lon) < 0.0001)) + { + finalResults.Add(res); + } + } - await Task.WhenAll(nominatimTask, stopsTask); + return Ok(finalResults); + } - var geocodingResults = await nominatimTask; - var allStops = await stopsTask; + private async Task<List<PlannerSearchResult>> SearchStops(string query) + { + var stops = await GetCachedStopsAsync(); // 1. Exact or prefix matches by stop code - var codeMatches = allStops + var codeMatches = stops .Where(s => s.StopCode != null && s.StopCode.StartsWith(query, StringComparison.OrdinalIgnoreCase)) .OrderBy(s => s.StopCode?.Length) // Shorter codes (more exact matches) first .Take(5) @@ -71,9 +100,13 @@ public partial class RoutePlannerController : ControllerBase // 2. Fuzzy search stops by label (Name + Code) var fuzzyResults = Process.ExtractSorted( query, - allStops.Select(s => s.Label ?? string.Empty), + stops.Select(s => s.Label ?? string.Empty), cutoff: 60 - ).Take(6).Select(r => allStops[r.Index]).ToList(); + ) + .OrderByDescending(r => r.Score) + .Take(6) + .Select(r => stops[r.Index]) + .ToList(); // Merge stops, prioritizing code matches var stopResults = codeMatches.Concat(fuzzyResults) @@ -82,18 +115,7 @@ public partial class RoutePlannerController : ControllerBase .Take(6) .ToList(); - // Merge results: geocoding first, then stops, deduplicating by coordinates (approx) - var finalResults = new List<PlannerSearchResult>(geocodingResults); - - 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)) - { - finalResults.Add(res); - } - } - - return Ok(finalResults); + return stopResults; } [HttpGet("reverse")] @@ -169,7 +191,7 @@ public partial class RoutePlannerController : ControllerBase return new PlannerSearchResult { Name = name, - Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})", + Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({feedId} {code}) -- {s.Desc}", Lat = s.Lat, Lon = s.Lon, Layer = "stop", diff --git a/src/Enmarcha.Backend/Services/FareService.cs b/src/Enmarcha.Backend/Services/FareService.cs index bf85f03..fda5eb5 100644 --- a/src/Enmarcha.Backend/Services/FareService.cs +++ b/src/Enmarcha.Backend/Services/FareService.cs @@ -22,6 +22,9 @@ public class FareService private const decimal SantiagoCashFare = 1.00M; private const decimal SantiagoCardFare = 0.36M; + private const decimal OurenseCashFare = 0.85M; + private const decimal OurenseCardFare = 0.49M; + public FareService( IOptions<AppConfiguration> config, XuntaFareProvider xuntaFareProvider, @@ -71,6 +74,9 @@ public class FareService case "vitrasa": total += VitrasaCashFare; break; + case "ourense": + total += OurenseCashFare; + break; case "xunta": // TODO: Handle potentiall blow-ups if (leg.From is not { ZoneId: not null }) @@ -126,6 +132,11 @@ public class FareService maxUsages = 2; initialFare = SantiagoCardFare; break; + case "ourense": + maxMinutes = 60; + maxUsages = 2; + initialFare = OurenseCardFare; + break; case "xunta": if (leg.From?.ZoneId == null || leg.To?.ZoneId == null) { @@ -182,9 +193,7 @@ public class FareService } else { - // Free transfer for city systems or non-ATM Xunta (though non-ATM Xunta has maxUsages=1) validTicket.UsedTimes++; - _logger.LogDebug("Free transfer for {FeedId}", leg.FeedId); } } else @@ -199,7 +208,6 @@ public class FareService StartZone = leg.FeedId == "xunta" ? leg.From!.ZoneId! : string.Empty, TotalPaid = initialFare }); - _logger.LogDebug("New ticket for {FeedId}: {Cost}€", leg.FeedId, initialFare); } } diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs index ce86c49..515613a 100644 --- a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs @@ -53,9 +53,17 @@ public class GeoapifyGeocodingService : IGeocodingService try { - var response = await _httpClient.GetFromJsonAsync<GeoapifyResult>(url + $"&apiKey={_config.GeoapifyApiKey}"); - + var httpResponse = await _httpClient.GetAsync(url + $"&apiKey={_config.GeoapifyApiKey}"); + if (!httpResponse.IsSuccessStatusCode) + { + var body = await httpResponse.Content.ReadAsStringAsync(); + _logger.LogWarning("Geoapify autocomplete returned {StatusCode} for query '{Query}': {Body}", + (int)httpResponse.StatusCode, query, body); + activity?.SetTag("http.status_code", (int)httpResponse.StatusCode); + return []; + } + var response = await httpResponse.Content.ReadFromJsonAsync<GeoapifyResult>(); var results = response?.results .Where(x => !ForbiddenResultTypes.Contains(x.result_type)) .Select(MapToPlannerSearchResult) @@ -69,7 +77,7 @@ public class GeoapifyGeocodingService : IGeocodingService { activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message); _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url); - return new List<PlannerSearchResult>(); + return []; } } @@ -90,8 +98,17 @@ public class GeoapifyGeocodingService : IGeocodingService $"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}"); + var httpResponse = await _httpClient.GetAsync(url + $"&apiKey={_config.GeoapifyApiKey}"); + if (!httpResponse.IsSuccessStatusCode) + { + var body = await httpResponse.Content.ReadAsStringAsync(); + _logger.LogWarning("Geoapify reverse geocode returned {StatusCode} for ({Lat},{Lon}): {Body}", + (int)httpResponse.StatusCode, lat, lon, body); + activity?.SetTag("http.status_code", (int)httpResponse.StatusCode); + return null; + } + var response = await httpResponse.Content.ReadFromJsonAsync<GeoapifyResult>(); if (response == null) return null; var result = MapToPlannerSearchResult(response.results[0]); diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs index 6079ea3..04e28d4 100644 --- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs +++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs @@ -31,6 +31,7 @@ public class StopTileRequestContent : IGraphRequest<StopTileRequestContent.TileR gtfsId code name + desc lat lon routes {{ @@ -61,6 +62,9 @@ public class StopTileResponse : AbstractGraphResponse [JsonPropertyName("name")] public required string Name { get; set; } + [JsonPropertyName("desc")] + public string? Desc { get; set; } + [JsonPropertyName("lat")] public required double Lat { get; set; } diff --git a/src/frontend/app/components/PlaceListItem.tsx b/src/frontend/app/components/PlaceListItem.tsx index 6c4f4a7..21250d6 100644 --- a/src/frontend/app/components/PlaceListItem.tsx +++ b/src/frontend/app/components/PlaceListItem.tsx @@ -1,8 +1,12 @@ -import { Building2, MapPin } from "lucide-react"; +import { Building2, BusFront, MapPin } from "lucide-react"; import type { PlannerSearchResult } from "~/data/PlannerApi"; function getIcon(layer?: string) { switch ((layer || "").toLowerCase()) { + case "stop": + return ( + <BusFront className="w-4 h-4 text-slate-600 dark:text-slate-400" /> + ); case "venue": return ( <Building2 className="w-4 h-4 text-slate-600 dark:text-slate-400" /> diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index c2fc648..d70da87 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -805,22 +805,24 @@ const ItineraryDetail = ({ )} </li> {/* Intermediate stops */} - {leg.intermediateStops.map((stop, sIdx) => ( - <li - key={sIdx} - className="flex items-center gap-1.5 py-0.5 px-1.5 text-gray-500 dark:text-gray-400" - > - <span className="w-1 h-1 rounded-full bg-gray-400 dark:bg-gray-500 inline-block shrink-0 ml-0.5" /> - <span className="flex-1"> - {stop.name} - </span> - {stop.stopCode && ( - <span className="text-[10px] text-gray-400 dark:text-gray-500 shrink-0"> - {stop.stopCode} + {leg.intermediateStops + .slice(1, -1) + .map((stop, sIdx) => ( + <li + key={sIdx} + className="flex items-center gap-1.5 py-0.5 px-1.5 text-gray-500 dark:text-gray-400" + > + <span className="w-1 h-1 rounded-full bg-gray-400 dark:bg-gray-500 inline-block shrink-0 ml-0.5" /> + <span className="flex-1"> + {stop.name} </span> - )} - </li> - ))} + {stop.stopCode && ( + <span className="text-[10px] text-gray-400 dark:text-gray-500 shrink-0"> + {stop.stopCode} + </span> + )} + </li> + ))} {/* Alighting stop */} <li className="flex items-center gap-1.5 py-0.5 px-1.5 rounded bg-primary/8 font-semibold text-primary"> <span className="w-1.5 h-1.5 rounded-full bg-primary inline-block shrink-0" /> |
