summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-21 00:36:25 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-21 00:36:25 +0100
commitf1b0b5f7ceaf6d23ae347e12cf29eef617c7dc9b (patch)
treed92c6075d019d87fd03482774050d7fc8981a5aa
parent2ef155c0c68208eccf968919fea12133698b50a9 (diff)
feat: enhance geocoding and stop search with performance metrics and improved response handling
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs64
-rw-r--r--src/Enmarcha.Backend/Services/FareService.cs14
-rw-r--r--src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs25
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs4
-rw-r--r--src/frontend/app/components/PlaceListItem.tsx6
-rw-r--r--src/frontend/app/routes/planner.tsx32
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" />