aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs14
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FareService.cs34
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs9
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs101
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs77
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs75
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs40
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs6
-rw-r--r--src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs1
-rw-r--r--src/frontend/app/api/schema.ts6
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx63
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx9
-rw-r--r--src/frontend/app/data/PlannerApi.ts7
-rw-r--r--src/frontend/app/data/StopDataProvider.ts17
-rw-r--r--src/frontend/app/hooks/usePlanQuery.ts4
-rw-r--r--src/frontend/app/hooks/usePlanner.ts178
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json1
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json1
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json1
-rw-r--r--src/frontend/app/root.css7
-rw-r--r--src/frontend/app/routes/home.tsx74
-rw-r--r--src/frontend/app/routes/map.tsx7
-rw-r--r--src/frontend/app/routes/planner.tsx256
-rw-r--r--src/frontend/app/tailwind-full.css28
26 files changed, 640 insertions, 378 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
index 3204e33..9e4d12f 100644
--- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
+++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
@@ -10,6 +10,7 @@ public class AppConfiguration
[Obsolete]
public required string OtpPlannerBaseUrl { get; set; } = "https://planificador-rutas.vigo.org/otp/routers/default";
public required string OpenTripPlannerBaseUrl { get; set; }
+ public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org";
// Default Routing Parameters
public double WalkSpeed { get; set; } = 1.4;
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs
index 7d47383..a7faf44 100644
--- a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs
+++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs
@@ -15,18 +15,21 @@ public partial class RoutePlannerController : ControllerBase
{
private readonly ILogger<RoutePlannerController> _logger;
private readonly OtpService _otpService;
+ private readonly IGeocodingService _geocodingService;
private readonly AppConfiguration _config;
private readonly HttpClient _httpClient;
public RoutePlannerController(
ILogger<RoutePlannerController> logger,
OtpService otpService,
+ IGeocodingService geocodingService,
IOptions<AppConfiguration> config,
HttpClient httpClient
)
{
_logger = logger;
_otpService = otpService;
+ _geocodingService = geocodingService;
_config = config.Value;
_httpClient = httpClient;
}
@@ -39,14 +42,14 @@ public partial class RoutePlannerController : ControllerBase
return BadRequest("Query cannot be empty");
}
- var results = await _otpService.GetAutocompleteAsync(query);
+ var results = await _geocodingService.GetAutocompleteAsync(query);
return Ok(results);
}
[HttpGet("reverse")]
public async Task<ActionResult<PlannerSearchResult>> Reverse([FromQuery] double lat, [FromQuery] double lon)
{
- var result = await _otpService.GetReverseGeocodeAsync(lat, lon);
+ var result = await _geocodingService.GetReverseGeocodeAsync(lat, lon);
if (result == null)
{
return NotFound();
@@ -60,13 +63,13 @@ public partial class RoutePlannerController : ControllerBase
[FromQuery] double fromLon,
[FromQuery] double toLat,
[FromQuery] double toLon,
- [FromQuery] DateTimeOffset time,
+ [FromQuery] DateTimeOffset? time,
[FromQuery] bool arriveBy = false)
{
try
{
var requestContent = PlanConnectionContent.Query(
- new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time, arriveBy)
+ new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time ?? DateTimeOffset.Now, arriveBy)
);
var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
@@ -78,7 +81,7 @@ public partial class RoutePlannerController : ControllerBase
var response = await _httpClient.SendAsync(request);
var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<PlanConnectionResponse>>();
- if (responseBody is not { IsSuccess: true } || responseBody.Data?.PlanConnection.Edges.Length == 0)
+ if (responseBody is not { IsSuccess: true })
{
LogErrorFetchingRoutes(response.StatusCode, await response.Content.ReadAsStringAsync());
return StatusCode(500, "An error occurred while planning the route.");
@@ -96,5 +99,4 @@ public partial class RoutePlannerController : ControllerBase
[LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")]
partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody);
-
}
diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs
index 94ebe62..97e7354 100644
--- a/src/Costasdev.Busurbano.Backend/Program.cs
+++ b/src/Costasdev.Busurbano.Backend/Program.cs
@@ -36,6 +36,7 @@ builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, FeedConfigProcessor>();
builder.Services.AddScoped<ArrivalsPipeline>();
+builder.Services.AddHttpClient<IGeocodingService, NominatimGeocodingService>();
builder.Services.AddHttpClient<OtpService>();
builder.Services.AddScoped<VitrasaTransitProvider>();
builder.Services.AddScoped<RenfeTransitProvider>();
diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs
index d0423e6..c08d1d5 100644
--- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs
+++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
namespace Costasdev.Busurbano.Backend.Services;
-public record FareResult(decimal CashFareEuro, decimal CardFareEuro);
+public record FareResult(decimal CashFareEuro, bool CashFareIsTotal, decimal CardFareEuro, bool CardFareIsTotal);
public class FareService
{
@@ -41,18 +41,23 @@ public class FareService
if (!transitLegs.Any())
{
- return new FareResult(0, 0);
+ return new FareResult(0, true, 0, true);
}
+ var cashResult = CalculateCashTotal(transitLegs);
+ var cardResult = CalculateCardTotal(transitLegs);
+
return new FareResult(
- CalculateCashTotal(transitLegs),
- CalculateCardTotal(transitLegs)
+ cashResult.Item1, cashResult.Item2,
+ cardResult.Item1, cardResult.Item2
);
}
- private decimal CalculateCashTotal(IEnumerable<Leg> legs)
+ private (decimal, bool) CalculateCashTotal(IEnumerable<Leg> legs)
{
decimal total = 0L;
+ bool allLegsProcessed = true;
+
foreach (var leg in legs)
{
switch (leg.FeedId)
@@ -80,21 +85,25 @@ public class FareService
total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash;
break;
+ default:
+ allLegsProcessed = false;
+ _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId);
+ break;
}
}
- return total;
+ return (total, allLegsProcessed);
}
- private decimal CalculateCardTotal(IEnumerable<Leg> legs)
+ private (decimal, bool) CalculateCardTotal(IEnumerable<Leg> legs)
{
List<TicketPurchased> wallet = [];
decimal totalCost = 0;
+ bool allLegsProcessed = true;
+
foreach (var leg in legs)
{
- _logger.LogDebug("Processing leg {leg}", leg);
-
int maxMinutes;
int maxUsages;
string? metroArea = null;
@@ -138,6 +147,7 @@ public class FareService
break;
default:
_logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId);
+ allLegsProcessed = false;
continue;
}
@@ -193,17 +203,17 @@ public class FareService
}
}
- return totalCost;
+ return (totalCost, allLegsProcessed);
}
}
public class TicketPurchased
{
- public string FeedId { get; set; }
+ public required string FeedId { get; set; }
public DateTime PurchasedAt { get; set; }
public string? MetroArea { get; set; }
- public string StartZone { get; set; }
+ public required string StartZone { get; set; }
public int UsedTimes = 1;
public decimal TotalPaid { get; set; }
diff --git a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs
new file mode 100644
index 0000000..3ac29d6
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs
@@ -0,0 +1,9 @@
+using Costasdev.Busurbano.Backend.Types.Planner;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public interface IGeocodingService
+{
+ Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query);
+ Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon);
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs
new file mode 100644
index 0000000..01e57f1
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs
@@ -0,0 +1,101 @@
+using System.Globalization;
+using Costasdev.Busurbano.Backend.Configuration;
+using Costasdev.Busurbano.Backend.Types.Nominatim;
+using Costasdev.Busurbano.Backend.Types.Planner;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class NominatimGeocodingService : IGeocodingService
+{
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<NominatimGeocodingService> _logger;
+ private readonly AppConfiguration _config;
+
+ private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7";
+
+ public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<NominatimGeocodingService> logger, IOptions<AppConfiguration> config)
+ {
+ _httpClient = httpClient;
+ _cache = cache;
+ _logger = logger;
+ _config = config.Value;
+
+ // Nominatim requires a User-Agent
+ if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
+ {
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Busurbano/1.0 (https://github.com/arielcostas/Busurbano)");
+ }
+ }
+
+ public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>();
+
+ var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null)
+ {
+ return cachedResults;
+ }
+
+ try
+ {
+ var url = $"{_config.NominatimBaseUrl}/search?q={Uri.EscapeDataString(query)}&format=jsonv2&viewbox={GaliciaBounds}&bounded=1&countrycodes=es&addressdetails=1";
+ var response = await _httpClient.GetFromJsonAsync<List<NominatimSearchResult>>(url);
+
+ var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List<PlannerSearchResult>();
+
+ _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30));
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Nominatim autocomplete results from {Url}", _config.NominatimBaseUrl);
+ 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;
+ }
+
+ try
+ {
+ var url = $"{_config.NominatimBaseUrl}/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&format=jsonv2&addressdetails=1";
+ var response = await _httpClient.GetFromJsonAsync<NominatimSearchResult>(url);
+
+ if (response == null) return null;
+
+ var result = MapToPlannerSearchResult(response);
+
+ _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60));
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Nominatim reverse geocode results from {Url}", _config.NominatimBaseUrl);
+ return null;
+ }
+ }
+
+ private PlannerSearchResult MapToPlannerSearchResult(NominatimSearchResult result)
+ {
+ var name = result.Address?.Road ?? result.DisplayName?.Split(',').FirstOrDefault();
+ var label = result.DisplayName;
+
+ return new PlannerSearchResult
+ {
+ Name = name,
+ Label = label,
+ Lat = double.TryParse(result.Lat, CultureInfo.InvariantCulture, out var lat) ? lat : 0,
+ Lon = double.TryParse(result.Lon, CultureInfo.InvariantCulture, out var lon) ? lon : 0,
+ Layer = result.Type
+ };
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
index b7e2d3f..704139d 100644
--- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
+++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
@@ -30,77 +30,6 @@ public class OtpService
_feedService = feedService;
}
- public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
- {
- if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>();
-
- var cacheKey = $"otp_autocomplete_{query.ToLowerInvariant()}";
- if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null)
- {
- return cachedResults;
- }
-
- try
- {
- // https://planificador-rutas-api.vigo.org/v1/autocomplete?text=XXXX&layers=venue,street,address&lang=es
- var url = $"{_config.OtpGeocodingBaseUrl}/autocomplete?text={Uri.EscapeDataString(query)}&layers=venue,address&lang=es";
- var response = await _httpClient.GetFromJsonAsync<OtpGeocodeResponse>(url);
-
- var results = response?.Features.Select(f => new PlannerSearchResult
- {
- Name = f.Properties?.Name,
- Label = $"{f.Properties?.PostalCode} {f.Properties?.LocalAdmin}, {f.Properties?.Region}",
- Layer = f.Properties?.Layer,
- Lat = f.Geometry?.Coordinates.Count > 1 ? f.Geometry.Coordinates[1] : 0,
- Lon = f.Geometry?.Coordinates.Count > 0 ? f.Geometry.Coordinates[0] : 0
- }).ToList() ?? new List<PlannerSearchResult>();
-
- _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30)); // Cache for 30 mins
- return results;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error fetching autocomplete results");
- return new List<PlannerSearchResult>();
- }
- }
-
- public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
- {
- var cacheKey = $"otp_reverse_{lat:F5}_{lon:F5}";
- if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null)
- {
- return cachedResult;
- }
-
- try
- {
- // https://planificador-rutas-api.vigo.org/v1/reverse?point.lat=LAT&point.lon=LON&lang=es
- var url = $"{_config.OtpGeocodingBaseUrl}/reverse?point.lat={lat.ToString(CultureInfo.InvariantCulture)}&point.lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=es";
- var response = await _httpClient.GetFromJsonAsync<OtpGeocodeResponse>(url);
-
- var feature = response?.Features.FirstOrDefault();
- if (feature == null) return null;
-
- var result = new PlannerSearchResult
- {
- Name = feature.Properties?.Name,
- Label = $"{feature.Properties?.PostalCode} {feature.Properties?.LocalAdmin}, {feature.Properties?.Region}",
- Layer = feature.Properties?.Layer,
- Lat = feature.Geometry?.Coordinates.Count > 1 ? feature.Geometry.Coordinates[1] : 0,
- Lon = feature.Geometry?.Coordinates.Count > 0 ? feature.Geometry.Coordinates[0] : 0
- };
-
- _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); // Cache for 1 hour
- return result;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error fetching reverse geocode results");
- return null;
- }
- }
-
private Leg MapLeg(OtpLeg otpLeg)
{
return new Leg
@@ -240,8 +169,10 @@ public class OtpService
TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds,
WaitingTimeSeconds = node.WaitingSeconds,
Legs = legs,
- CashFareEuro = fares.CashFareEuro,
- CardFareEuro = fares.CardFareEuro
+ CashFare = fares.CashFareEuro,
+ CashFareIsTotal = fares.CashFareIsTotal,
+ CardFare = fares.CardFareEuro,
+ CardFareIsTotal = fares.CardFareIsTotal
};
}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs b/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs
new file mode 100644
index 0000000..a73cdd2
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs
@@ -0,0 +1,75 @@
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Backend.Types.Nominatim;
+
+public class NominatimSearchResult
+{
+ [JsonPropertyName("place_id")]
+ public long PlaceId { get; set; }
+
+ [JsonPropertyName("licence")]
+ public string? Licence { get; set; }
+
+ [JsonPropertyName("osm_type")]
+ public string? OsmType { get; set; }
+
+ [JsonPropertyName("osm_id")]
+ public long OsmId { get; set; }
+
+ [JsonPropertyName("lat")]
+ public string? Lat { get; set; }
+
+ [JsonPropertyName("lon")]
+ public string? Lon { get; set; }
+
+ [JsonPropertyName("display_name")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("address")]
+ public NominatimAddress? Address { get; set; }
+
+ [JsonPropertyName("extratags")]
+ public Dictionary<string, string>? ExtraTags { get; set; }
+
+ [JsonPropertyName("category")]
+ public string? Category { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("importance")]
+ public double Importance { get; set; }
+}
+
+public class NominatimAddress
+{
+ [JsonPropertyName("house_number")]
+ public string? HouseNumber { get; set; }
+
+ [JsonPropertyName("road")]
+ public string? Road { get; set; }
+
+ [JsonPropertyName("suburb")]
+ public string? Suburb { get; set; }
+
+ [JsonPropertyName("city")]
+ public string? City { get; set; }
+
+ [JsonPropertyName("municipality")]
+ public string? Municipality { get; set; }
+
+ [JsonPropertyName("county")]
+ public string? County { get; set; }
+
+ [JsonPropertyName("state")]
+ public string? State { get; set; }
+
+ [JsonPropertyName("postcode")]
+ public string? Postcode { get; set; }
+
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ [JsonPropertyName("country_code")]
+ public string? CountryCode { get; set; }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs
index b67663d..2c076a2 100644
--- a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs
+++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs
@@ -163,43 +163,3 @@ public class OtpWalkStep
[JsonPropertyName("lon")]
public double Lon { get; set; }
}
-
-// Geocoding Models (Pelias-like)
-public class OtpGeocodeResponse
-{
- [JsonPropertyName("features")]
- public List<OtpGeocodeFeature> Features { get; set; } = new();
-}
-
-public class OtpGeocodeFeature
-{
- [JsonPropertyName("geometry")]
- public OtpGeocodeGeometry? Geometry { get; set; }
-
- [JsonPropertyName("properties")]
- public OtpGeocodeProperties? Properties { get; set; }
-}
-
-public class OtpGeocodeGeometry
-{
- [JsonPropertyName("coordinates")]
- public List<double> Coordinates { get; set; } = new(); // [lon, lat]
-}
-
-public class OtpGeocodeProperties
-{
- [JsonPropertyName("name")]
- public string? Name { get; set; }
-
- [JsonPropertyName("postalcode")]
- public string? PostalCode { get; set; }
-
- [JsonPropertyName("localadmin")]
- public string? LocalAdmin { get; set; }
-
- [JsonPropertyName("region")]
- public string? Region { get; set; }
-
- [JsonPropertyName("layer")]
- public string? Layer { get; set; }
-}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs
index f88942f..a0cf754 100644
--- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs
+++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs
@@ -16,8 +16,10 @@ public class Itinerary
public double TransitTimeSeconds { get; set; }
public double WaitingTimeSeconds { get; set; }
public List<Leg> Legs { get; set; } = [];
- public decimal? CashFareEuro { get; set; }
- public decimal? CardFareEuro { get; set; }
+ public decimal? CashFare { get; set; }
+ public bool? CashFareIsTotal { get; set; }
+ public decimal? CardFare { get; set; }
+ public bool? CardFareIsTotal { get; set; }
}
public class Leg
diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs
index b65af50..db3de49 100644
--- a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs
+++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs
@@ -54,6 +54,7 @@ public class PlanConnectionContent : IGraphRequest<PlanConnectionContent.Args>
dateTime:{
{{dateTimeParameter}}
}
+ searchWindow:"PT5H"
) {
edges {
node {
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index bb2fbcc..63f4368 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -163,8 +163,10 @@ export const ItinerarySchema = z.object({
transitTimeSeconds: z.number(),
waitingTimeSeconds: z.number(),
legs: z.array(PlannerLegSchema),
- cashFareEuro: z.number().optional().nullable(),
- cardFareEuro: z.number().optional().nullable(),
+ cashFare: z.number().optional().nullable(),
+ cashFareIsTotal: z.boolean().optional().nullable(),
+ cardFare: z.number().optional().nullable(),
+ cardFareIsTotal: z.boolean().optional().nullable(),
});
export const RoutePlanSchema = z.object({
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index af71e48..55e52d7 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -29,6 +29,8 @@ interface PlannerOverlayProps {
clearPickerOnOpen?: boolean;
showLastDestinationWhenCollapsed?: boolean;
cardBackground?: string;
+ userLocation?: { latitude: number; longitude: number } | null;
+ autoLoad?: boolean;
}
export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
@@ -39,10 +41,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen = false,
showLastDestinationWhenCollapsed = true,
cardBackground,
+ userLocation,
+ autoLoad = true,
}) => {
const { t } = useTranslation();
const { origin, setOrigin, destination, setDestination, loading, error } =
- usePlanner();
+ usePlanner({ autoLoad });
const [isExpanded, setIsExpanded] = useState(false);
const [originQuery, setOriginQuery] = useState(origin?.name || "");
const [destQuery, setDestQuery] = useState("");
@@ -85,6 +89,21 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
: origin?.name || ""
);
}, [origin, t]);
+
+ useEffect(() => {
+ if (userLocation && !origin) {
+ const initial: PlannerSearchResult = {
+ name: t("planner.current_location"),
+ label: "GPS",
+ lat: userLocation.latitude,
+ lon: userLocation.longitude,
+ layer: "current-location",
+ };
+ setOrigin(initial);
+ setOriginQuery(initial.name || "");
+ }
+ }, [userLocation, origin, t, setOrigin]);
+
useEffect(() => {
setDestQuery(destination?.name || "");
}, [destination]);
@@ -185,14 +204,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery
);
setPickerOpen(true);
-
- // When opening destination picker, auto-fill origin from current location if not set
- if (field === "destination" && !origin) {
- console.log(
- "[PlannerOverlay] Destination picker opened with no origin, requesting geolocation"
- );
- setOriginFromCurrentLocation(false);
- }
};
const applyPickedResult = (result: PlannerSearchResult) => {
@@ -323,11 +334,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
const wrapperClass = inline
? "w-full"
- : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center";
+ : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center mb-3";
const cardClass = inline
- ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-3 ${cardBackground || "bg-white dark:bg-slate-900"}`
- : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"}`;
+ ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-4 ${cardBackground || "bg-white dark:bg-slate-900"} mb-3`
+ : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-4 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"} mb-3`;
return (
<div className={wrapperClass}>
@@ -349,10 +360,10 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
</button>
) : (
<>
- <div className="flex items-center gap-">
+ <div className="flex items-center gap-2">
<button
type="button"
- className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
onClick={() => openPicker("origin")}
>
<span
@@ -368,7 +379,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div>
<button
type="button"
- className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
onClick={() => openPicker("destination")}
>
<span
@@ -383,13 +394,13 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<span className="font-semibold">{t("planner.when")}</span>
- <div className="flex gap-1 rounded-2xl bg-slate-100 dark:bg-slate-800 p-1">
+ <div className="flex gap-1 rounded-2xl bg-surface border border-slate-200 dark:border-slate-700 p-1 shadow-sm">
<button
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "now"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("now")}
>
@@ -399,8 +410,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "depart"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("depart")}
>
@@ -410,8 +421,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "arrive"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("arrive")}
>
@@ -421,7 +432,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
{timeMode !== "now" && (
<div className="flex gap-2 w-full">
<select
- className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow"
+ className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm"
value={dateOffset}
onChange={(e) => setDateOffset(Number(e.target.value))}
>
@@ -447,7 +458,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
</select>
<input
type="time"
- className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow"
+ className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm"
value={timeValue}
onChange={(e) => setTimeValue(e.target.value)}
/>
@@ -457,7 +468,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div>
<button
- className="w-full rounded-lg bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-800 px-2 py-2 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none"
+ className="w-full rounded-xl bg-primary-600 hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-800 px-2 py-2.5 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none"
disabled={!canSubmit}
onClick={async () => {
if (origin && destination) {
@@ -543,7 +554,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div className="relative">
<input
ref={pickerInputRef}
- className="w-full pr-12 px-4 py-3 text-base border border-slate-300 dark:border-slate-600 rounded-2xl bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
+ className="w-full pr-12 px-4 py-3 text-base border border-slate-200 dark:border-slate-700 rounded-2xl bg-surface text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:border-primary-500 shadow-sm transition-all duration-200"
placeholder={
pickerField === "origin"
? t("planner.search_origin")
diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx
index 58228c7..fab47e0 100644
--- a/src/frontend/app/components/layout/NavBar.tsx
+++ b/src/frontend/app/components/layout/NavBar.tsx
@@ -1,4 +1,4 @@
-import { Home, Map, Navigation2, Route } from "lucide-react";
+import { Home, Map, Route } from "lucide-react";
import type { LngLatLike } from "maplibre-gl";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate } from "react-router";
@@ -30,7 +30,7 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
const { mapState, updateMapState, mapPositionMode } = useApp();
const location = useLocation();
const navigate = useNavigate();
- const { deselectItinerary } = usePlanner();
+ const { deselectItinerary } = usePlanner({ autoLoad: false });
const navItems = [
{
@@ -70,11 +70,6 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
},
},
{
- name: t("navbar.planner", "Planificador"),
- icon: Navigation2,
- path: "/planner",
- },
- {
name: t("navbar.lines", "Líneas"),
icon: Route,
path: "/lines",
diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts
index be61d4b..4c78004 100644
--- a/src/frontend/app/data/PlannerApi.ts
+++ b/src/frontend/app/data/PlannerApi.ts
@@ -20,8 +20,10 @@ export interface Itinerary {
transitTimeSeconds: number;
waitingTimeSeconds: number;
legs: Leg[];
- cashFareEuro?: number;
- cardFareEuro?: number;
+ cashFare?: number;
+ cashFareIsTotal?: boolean;
+ cardFare?: number;
+ cardFareIsTotal?: boolean;
}
export interface Leg {
@@ -30,6 +32,7 @@ export interface Leg {
routeShortName?: string;
routeLongName?: string;
routeColor?: string;
+ routeTextColor?: string;
headsign?: string;
agencyName?: string;
from?: PlannerPlace;
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 7bab10c..697e171 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -73,7 +73,7 @@ async function initStops() {
async function getStops(): Promise<Stop[]> {
await initStops();
// update favourites
- const rawFav = localStorage.getItem("favouriteStops_vigo");
+ const rawFav = localStorage.getItem("favouriteStops");
const favouriteStops = rawFav
? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId)
: [];
@@ -136,7 +136,7 @@ function getCustomName(stopId: string | number): string | undefined {
function addFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -146,13 +146,13 @@ function addFavourite(stopId: string | number) {
if (!favouriteStops.includes(id)) {
favouriteStops.push(id);
- localStorage.setItem(`favouriteStops_vigo`, JSON.stringify(favouriteStops));
+ localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops));
}
}
function removeFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -161,15 +161,12 @@ function removeFavourite(stopId: string | number) {
}
const newFavouriteStops = favouriteStops.filter((sid) => sid !== id);
- localStorage.setItem(
- `favouriteStops_vigo`,
- JSON.stringify(newFavouriteStops)
- );
+ localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops));
}
function isFavourite(stopId: string | number): boolean {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
const favouriteStops = (
JSON.parse(rawFavouriteStops) as (number | string)[]
@@ -213,7 +210,7 @@ function getRecent(): string[] {
}
function getFavouriteIds(): string[] {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
return (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
normalizeId
diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts
index 103f5f4..8c81073 100644
--- a/src/frontend/app/hooks/usePlanQuery.ts
+++ b/src/frontend/app/hooks/usePlanQuery.ts
@@ -8,7 +8,8 @@ export const usePlanQuery = (
toLon: number | undefined,
time?: Date,
arriveBy: boolean = false,
- enabled: boolean = true
+ enabled: boolean = true,
+ initialData?: any
) => {
return useQuery({
queryKey: [
@@ -25,5 +26,6 @@ export const usePlanQuery = (
enabled: !!(fromLat && fromLon && toLat && toLon) && enabled,
staleTime: 60000, // 1 minute
retry: false,
+ initialData,
});
};
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
index a28167a..445a426 100644
--- a/src/frontend/app/hooks/usePlanner.ts
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -1,21 +1,23 @@
+import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
import { usePlanQuery } from "./usePlanQuery";
-const STORAGE_KEY = "planner_last_route";
+const STORAGE_KEY = "planner_route_history";
const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
interface StoredRoute {
timestamp: number;
origin: PlannerSearchResult;
destination: PlannerSearchResult;
- plan: RoutePlan;
+ plan?: RoutePlan;
searchTime?: Date;
arriveBy?: boolean;
selectedItineraryIndex?: number;
}
-export function usePlanner() {
+export function usePlanner(options: { autoLoad?: boolean } = {}) {
+ const { autoLoad = true } = options;
const [origin, setOrigin] = useState<PlannerSearchResult | null>(null);
const [destination, setDestination] = useState<PlannerSearchResult | null>(
null
@@ -28,6 +30,8 @@ export function usePlanner() {
const [selectedItineraryIndex, setSelectedItineraryIndex] = useState<
number | null
>(null);
+ const [history, setHistory] = useState<StoredRoute[]>([]);
+ const queryClient = useQueryClient();
const {
data: queryPlan,
@@ -41,13 +45,13 @@ export function usePlanner() {
destination?.lon,
searchTime ?? undefined,
arriveBy,
- !!(origin && destination)
+ !!(origin && destination && searchTime)
);
// Sync query result to local state and storage
useEffect(() => {
if (queryPlan) {
- setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now
+ setPlan(queryPlan as any);
if (origin && destination) {
const toStore: StoredRoute = {
@@ -59,7 +63,21 @@ export function usePlanner() {
arriveBy,
selectedItineraryIndex: selectedItineraryIndex ?? undefined,
};
- localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === origin.lat &&
+ r.origin.lon === origin.lon &&
+ r.destination.lat === destination.lat &&
+ r.destination.lon === destination.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}
}
}, [
@@ -76,22 +94,40 @@ export function usePlanner() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
- const data: StoredRoute = JSON.parse(stored);
- if (Date.now() - data.timestamp < EXPIRY_MS) {
- setOrigin(data.origin);
- setDestination(data.destination);
- setPlan(data.plan);
- setSearchTime(data.searchTime ? new Date(data.searchTime) : null);
- setArriveBy(data.arriveBy ?? false);
- setSelectedItineraryIndex(data.selectedItineraryIndex ?? null);
- } else {
- localStorage.removeItem(STORAGE_KEY);
+ const data: StoredRoute[] = JSON.parse(stored);
+ const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS);
+ setHistory(valid);
+
+ if (autoLoad && valid.length > 0) {
+ const last = valid[0];
+ if (last.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ last.origin.lat,
+ last.origin.lon,
+ last.destination.lat,
+ last.destination.lon,
+ last.searchTime
+ ? new Date(last.searchTime).toISOString()
+ : undefined,
+ last.arriveBy ?? false,
+ ],
+ last.plan
+ );
+ setPlan(last.plan);
+ }
+ setOrigin(last.origin);
+ setDestination(last.destination);
+ setSearchTime(last.searchTime ? new Date(last.searchTime) : null);
+ setArriveBy(last.arriveBy ?? false);
+ setSelectedItineraryIndex(last.selectedItineraryIndex ?? null);
}
} catch (e) {
localStorage.removeItem(STORAGE_KEY);
}
}
- }, []);
+ }, [autoLoad]);
const searchRoute = async (
from: PlannerSearchResult,
@@ -101,9 +137,78 @@ export function usePlanner() {
) => {
setOrigin(from);
setDestination(to);
- setSearchTime(time ?? new Date());
+ const finalTime = time ?? new Date();
+ setSearchTime(finalTime);
setArriveBy(arriveByParam);
setSelectedItineraryIndex(null);
+
+ // Save to history immediately so other pages can pick it up
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin: from,
+ destination: to,
+ searchTime: finalTime,
+ arriveBy: arriveByParam,
+ };
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === from.lat &&
+ r.origin.lon === from.lon &&
+ r.destination.lat === to.lat &&
+ r.destination.lon === to.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ };
+
+ const loadRoute = (route: StoredRoute) => {
+ if (route.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ route.origin.lat,
+ route.origin.lon,
+ route.destination.lat,
+ route.destination.lon,
+ route.searchTime
+ ? new Date(route.searchTime).toISOString()
+ : undefined,
+ route.arriveBy ?? false,
+ ],
+ route.plan
+ );
+ setPlan(route.plan);
+ }
+ setOrigin(route.origin);
+ setDestination(route.destination);
+ setSearchTime(route.searchTime ? new Date(route.searchTime) : null);
+ setArriveBy(route.arriveBy ?? false);
+ setSelectedItineraryIndex(route.selectedItineraryIndex ?? null);
+
+ // Move to top of history
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === route.origin.lat &&
+ r.origin.lon === route.origin.lon &&
+ r.destination.lat === route.destination.lat &&
+ r.destination.lon === route.destination.lon
+ )
+ );
+ const updated = [{ ...route, timestamp: Date.now() }, ...filtered].slice(
+ 0,
+ 3
+ );
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
};
const clearRoute = () => {
@@ -113,6 +218,7 @@ export function usePlanner() {
setSearchTime(null);
setArriveBy(false);
setSelectedItineraryIndex(null);
+ setHistory([]);
localStorage.removeItem(STORAGE_KEY);
};
@@ -120,32 +226,26 @@ export function usePlanner() {
setSelectedItineraryIndex(index);
// Update storage
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- try {
- const data: StoredRoute = JSON.parse(stored);
- data.selectedItineraryIndex = index;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
- } catch (e) {
- // Ignore
- }
- }
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: index };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}, []);
const deselectItinerary = useCallback(() => {
setSelectedItineraryIndex(null);
// Update storage
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- try {
- const data: StoredRoute = JSON.parse(stored);
- data.selectedItineraryIndex = undefined;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
- } catch (e) {
- // Ignore
- }
- }
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: undefined };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}, []);
return {
@@ -159,7 +259,9 @@ export function usePlanner() {
searchTime,
arriveBy,
selectedItineraryIndex,
+ history,
searchRoute,
+ loadRoute,
clearRoute,
selectItinerary,
deselectItinerary,
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 2c58ebe..91c836a 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -128,6 +128,7 @@
"close": "Close",
"results_title": "Results",
"clear": "Clear",
+ "recent_routes": "Recent routes",
"no_routes_found": "No routes found",
"no_routes_message": "We couldn't find a route for your trip. Try changing the time or locations.",
"walk": "Walk",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 298733e..526ab2f 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -128,6 +128,7 @@
"close": "Cerrar",
"results_title": "Resultados",
"clear": "Borrar",
+ "recent_routes": "Rutas recientes",
"no_routes_found": "No se encontraron rutas",
"no_routes_message": "No pudimos encontrar una ruta para tu viaje. Intenta cambiar la hora o las ubicaciones.",
"walk": "Caminar",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 833279f..eec7ab9 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -128,6 +128,7 @@
"close": "Pechar",
"results_title": "Resultados",
"clear": "Limpar",
+ "recent_routes": "Rutas recentes",
"no_routes_found": "Non se atoparon rutas",
"no_routes_message": "Non puidemos atopar unha ruta para a túa viaxe. Intenta cambiar a hora ou as localizacións.",
"walk": "Camiñar",
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index 9f79b08..3f41591 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -216,6 +216,11 @@ textarea {
color: var(--ml-c-link-2);
}
-.maplibregl-ctrl button .maplibregl-ctrl-icon:before {
+.maplibregl-ctrl button .maplibregl-ctrl-icon:before,
+.maplibregl-ctrl-attrib-button::before {
display: none !important;
}
+
+.maplibregl-ctrl-attrib-inner {
+ line-height: 1rem !important;
+}
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index a20ba64..b20a349 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,7 +1,11 @@
import Fuse from "fuse.js";
+import { History } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+import { PlannerOverlay } from "~/components/PlannerOverlay";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { usePlanner } from "~/hooks/usePlanner";
import StopGallery from "../components/StopGallery";
import StopItem from "../components/StopItem";
import StopItemSkeleton from "../components/StopItemSkeleton";
@@ -11,6 +15,8 @@ import "../tailwind-full.css";
export default function StopList() {
const { t } = useTranslation();
usePageTitle(t("navbar.stops", "Paradas"));
+ const navigate = useNavigate();
+ const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false });
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
@@ -239,9 +245,73 @@ export default function StopList() {
return (
<div className="flex flex-col gap-4 py-4 pb-8">
+ {/* Planner Section */}
+ <div className="w-full px-4">
+ <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm">
+ <summary className="list-none cursor-pointer focus:outline-none">
+ <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all">
+ <div className="flex items-center gap-3">
+ <History className="w-5 h-5 text-primary-600 dark:text-primary-400" />
+ <span className="font-semibold text-text">
+ {t("planner.where_to", "¿A dónde quieres ir?")}
+ </span>
+ </div>
+ <div className="text-muted group-open:rotate-180 transition-transform">
+ ↓
+ </div>
+ </div>
+ </summary>
+
+ <PlannerOverlay
+ inline
+ forceExpanded
+ cardBackground="bg-transparent"
+ userLocation={userLocation}
+ autoLoad={false}
+ onSearch={(origin, destination, time, arriveBy) => {
+ searchRoute(origin, destination, time, arriveBy);
+ }}
+ onNavigateToPlanner={() => navigate("/planner")}
+ />
+ </details>
+
+ {history.length > 0 && (
+ <div className="mt-3 flex flex-col gap-2">
+ <h4 className="text-xs font-bold uppercase tracking-wider text-muted px-1">
+ {t("planner.recent_routes", "Rutas recientes")}
+ </h4>
+ <div className="flex flex-col gap-1">
+ {history.map((route, idx) => (
+ <button
+ key={idx}
+ onClick={() => {
+ loadRoute(route);
+ navigate("/planner");
+ }}
+ className="flex items-center gap-3 p-3 rounded-xl bg-surface border border-border hover:bg-surface/80 transition-colors text-left"
+ >
+ <History className="w-4 h-4 text-muted shrink-0" />
+ <div className="flex flex-col min-w-0">
+ <span className="text-sm font-semibold text-text truncate">
+ {route.destination.name}
+ </span>
+ <span className="text-xs text-muted truncate">
+ {t("planner.from_to", {
+ from: route.origin.name,
+ to: route.destination.name,
+ })}
+ </span>
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
{/* Search Section */}
<div className="w-full px-4">
- <h3 className="text-lg font-semibold mb-2 text-text">
+ <h3 className="text-xs font-bold uppercase tracking-wider text-muted mb-2 px-1">
{t("stoplist.search_label", "Buscar paradas")}
</h3>
<input
@@ -249,7 +319,7 @@ export default function StopList() {
placeholder={randomPlaceholder}
onChange={handleStopSearch}
className="
- w-full px-4 py-3 text-base
+ w-full px-4 py-2 text-sm
border border-border rounded-xl
bg-surface
text-text
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index cccdaa3..b02c494 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -38,7 +38,7 @@ export default function StopMap() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const mapRef = useRef<MapRef>(null);
- const { searchRoute } = usePlanner();
+ const { searchRoute } = usePlanner({ autoLoad: false });
// Handle click events on clusters and individual stops
const onMapClick = (e: MapLayerMouseEvent) => {
@@ -58,7 +58,7 @@ export default function StopMap() {
};
const stopLayerFilter = useMemo(() => {
- const filter: FilterSpecification = ["any"];
+ const filter: any[] = ["any"];
if (showCitybusStops) {
filter.push(["==", ["get", "transitKind"], "bus"]);
}
@@ -68,7 +68,7 @@ export default function StopMap() {
if (showTrainStops) {
filter.push(["==", ["get", "transitKind"], "train"]);
}
- return filter;
+ return filter as FilterSpecification;
}, [showCitybusStops, showIntercityBusStops, showTrainStops]);
const getLatitude = (center: any) =>
@@ -119,6 +119,7 @@ export default function StopMap() {
clearPickerOnOpen={true}
showLastDestinationWhenCollapsed={false}
cardBackground="bg-white/95 dark:bg-slate-900/90"
+ autoLoad={false}
/>
<AppMap
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index 5968bc2..b71d211 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -6,12 +6,13 @@ import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema";
+import { type ConsolidatedCirculation } from "~/api/schema";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
@@ -21,6 +22,14 @@ const formatDistance = (meters: number) => {
return `${rounded} m`;
};
+const formatDuration = (minutes: number, t: any) => {
+ if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`;
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (m === 0) return `${h}h`;
+ return `${h}h ${m}min`;
+};
+
const haversineMeters = (a: [number, number], b: [number, number]) => {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371000;
@@ -84,11 +93,8 @@ const ItinerarySummary = ({
});
const walkTotals = sumWalkMetrics(itinerary.legs);
- const busLegsCount = itinerary.legs.filter(
- (leg) => leg.mode !== "WALK"
- ).length;
- const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2);
- const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2);
+ const cashFare = (itinerary.cashFare ?? 0).toFixed(2);
+ const cardFare = (itinerary.cardFare ?? 0).toFixed(2);
return (
<div
@@ -99,7 +105,7 @@ const ItinerarySummary = ({
<div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-muted">{durationMinutes} min</div>
+ <div className="text-muted">{formatDuration(durationMinutes, t)}</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
@@ -125,7 +131,7 @@ const ItinerarySummary = ({
<div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border">
<Footprints className="w-4 h-4 text-muted" />
<span className="font-semibold">
- {legDurationMinutes} {t("estimates.minutes")}
+ {formatDuration(legDurationMinutes, t)}
</span>
</div>
) : (
@@ -147,7 +153,7 @@ const ItinerarySummary = ({
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
+ ? ` • ${formatDuration(walkTotals.minutes, t)}`
: ""}
</span>
<span className="flex items-center gap-3">
@@ -156,12 +162,14 @@ const ItinerarySummary = ({
{cashFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cashFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
<span className="flex items-center gap-1 text-muted">
<CreditCard className="w-4 h-4" />
{cardFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cardFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
</span>
</div>
@@ -206,83 +214,39 @@ const ItineraryDetail = ({
// Create GeoJSON for all markers
const markersGeoJson = useMemo(() => {
const features: any[] = [];
- const origin = itinerary.legs[0]?.from;
- const destination = itinerary.legs[itinerary.legs.length - 1]?.to;
-
- // Origin marker (red)
- if (origin?.lat && origin?.lon) {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [origin.lon, origin.lat] },
- properties: { type: "origin", name: origin.name || "Origin" },
- });
- }
-
- // Destination marker (green)
- if (destination?.lat && destination?.lon) {
- features.push({
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [destination.lon, destination.lat],
- },
- properties: {
- type: "destination",
- name: destination.name || "Destination",
- },
- });
- }
- // Collect unique stops with their roles (board, alight, transfer)
- const stopsMap: Record<
- string,
- { lat: number; lon: number; name: string; type: string }
- > = {};
+ // Add points for each leg transition
itinerary.legs.forEach((leg, idx) => {
- if (leg.mode !== "WALK") {
- // Boarding stop
- if (leg.from?.lat && leg.from?.lon) {
- const key = `${leg.from.lat},${leg.from.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx > 0 && itinerary.legs[idx - 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.from.lat,
- lon: leg.from.lon,
- name: leg.from.name || "",
- type: isTransfer ? "transfer" : "board",
- };
- }
- }
- // Alighting stop
- if (leg.to?.lat && leg.to?.lon) {
- const key = `${leg.to.lat},${leg.to.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx < itinerary.legs.length - 1 &&
- itinerary.legs[idx + 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.to.lat,
- lon: leg.to.lon,
- name: leg.to.name || "",
- type: isTransfer ? "transfer" : "alight",
- };
- }
- }
+ // Add "from" point of the leg
+ if (leg.from?.lat && leg.from?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [leg.from.lon, leg.from.lat],
+ },
+ properties: {
+ type: idx === 0 ? "origin" : "transfer",
+ name: leg.from.name || "",
+ index: idx.toString(),
+ },
+ });
}
- });
- // Add stop markers
- Object.values(stopsMap).forEach((stop) => {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [stop.lon, stop.lat] },
- properties: { type: stop.type, name: stop.name },
- });
- });
+ // If it's the last leg, also add the "to" point
+ if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] },
+ properties: {
+ type: "destination",
+ name: leg.to.name || "",
+ index: (idx + 1).toString(),
+ },
+ });
+ }
- // Add intermediate stops
- itinerary.legs.forEach((leg) => {
+ // Add intermediate stops
leg.intermediateStops?.forEach((stop) => {
features.push({
type: "Feature",
@@ -389,7 +353,9 @@ const ItineraryDetail = ({
zoom: 13,
}}
showTraffic={false}
- attributionControl={false}
+ showGeolocate={true}
+ showNavigation={true}
+ attributionControl={true}
>
<Source id="route" type="geojson" data={routeGeoJson as any}>
<Layer
@@ -411,69 +377,36 @@ const ItineraryDetail = ({
{/* All markers as GeoJSON layers */}
<Source id="markers" type="geojson" data={markersGeoJson as any}>
- {/* Outer circle for origin/destination markers */}
+ {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */}
<Layer
- id="markers-outer"
- type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
- paint={{
- "circle-radius": [
- "interpolate",
- ["linear"],
- ["zoom"],
- 10,
- 6,
- 16,
- 8,
- 20,
- 10,
- ],
- "circle-color": [
- "case",
- ["==", ["get", "type"], "origin"],
- "#dc2626",
- "#16a34a",
- ],
- "circle-stroke-width": 2,
- "circle-stroke-color": "#ffffff",
- }}
- />
- {/* Inner circle for origin/destination markers */}
- <Layer
- id="markers-inner"
+ id="markers-intermediate"
type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
+ filter={["==", ["get", "type"], "intermediate"]}
paint={{
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
- 16,
3,
+ 16,
+ 5,
20,
- 4,
+ 7,
],
"circle-color": "#ffffff",
+ "circle-stroke-width": 1.5,
+ "circle-stroke-color": "#6b7280",
}}
/>
- {/* Stop markers (board, alight, transfer) */}
+ {/* Outer circle for all numbered markers */}
<Layer
- id="markers-stops"
+ id="markers-outer"
type="circle"
filter={[
"in",
["get", "type"],
- ["literal", ["board", "alight", "transfer"]],
+ ["literal", ["origin", "destination", "transfer"]],
]}
paint={{
"circle-radius": [
@@ -481,44 +414,51 @@ const ItineraryDetail = ({
["linear"],
["zoom"],
10,
- 4,
+ 8,
16,
- 6,
+ 10,
20,
- 7,
+ 12,
],
"circle-color": [
"case",
- ["==", ["get", "type"], "board"],
+ ["==", ["get", "type"], "origin"],
+ "#dc2626",
+ ["==", ["get", "type"], "destination"],
+ "#16a34a",
"#3b82f6",
- ["==", ["get", "type"], "alight"],
- "#a855f7",
- "#f97316",
],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
}}
/>
- {/* Intermediate stops (smaller white dots) */}
+ {/* Numbers for markers */}
<Layer
- id="markers-intermediate"
- type="circle"
- filter={["==", ["get", "type"], "intermediate"]}
- paint={{
- "circle-radius": [
+ id="markers-labels"
+ type="symbol"
+ filter={[
+ "in",
+ ["get", "type"],
+ ["literal", ["origin", "destination", "transfer"]],
+ ]}
+ layout={{
+ "text-field": ["get", "index"],
+ "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
+ "text-size": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
+ 8,
16,
- 3,
+ 10,
20,
- 4,
+ 12,
],
- "circle-color": "#ffffff",
- "circle-stroke-width": 1,
- "circle-stroke-color": "#9ca3af",
+ "text-allow-overlap": true,
+ }}
+ paint={{
+ "text-color": "#ffffff",
}}
/>
</Source>
@@ -590,12 +530,14 @@ const ItineraryDetail = ({
</span>
<span>•</span>
<span>
- {(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ).toFixed(0)}{" "}
- {t("estimates.minutes")}
+ {formatDuration(
+ Math.round(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ),
+ t
+ )}
</span>
<span>•</span>
<span>{formatDistance(leg.distanceMeters)}</span>
@@ -654,8 +596,8 @@ const ItineraryDetail = ({
<span className="flex-1 truncate">
{circ.route}
</span>
- <span className="font-semibold text-emerald-600 dark:text-emerald-400">
- {minutes} {t("estimates.minutes")}
+ <span className="font-semibold text-primary-600 dark:text-primary-400">
+ {formatDuration(minutes, t)}
{circ.realTime && " 🟢"}
</span>
</div>
@@ -735,6 +677,7 @@ export default function PlannerPage() {
const location = useLocation();
const {
plan,
+ loading,
searchRoute,
clearRoute,
searchTime,
@@ -815,6 +758,13 @@ export default function PlannerPage() {
cardBackground="bg-transparent"
/>
+ {loading && !plan && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <div className="w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div>
+ <p className="text-muted">{t("planner.searching")}</p>
+ </div>
+ )}
+
{plan && (
<div>
<div className="flex justify-between items-center my-4">
diff --git a/src/frontend/app/tailwind-full.css b/src/frontend/app/tailwind-full.css
index 1767d61..e7c4dd3 100644
--- a/src/frontend/app/tailwind-full.css
+++ b/src/frontend/app/tailwind-full.css
@@ -1,3 +1,31 @@
@import "tailwindcss";
+@theme {
+ --color-primary: var(--button-background-color);
+ --color-background: var(--background-color);
+ --color-text: var(--text-color);
+ --color-subtitle: var(--subtitle-color);
+ --color-border: var(--border-color);
+ --color-surface: var(--message-background-color);
+
+ --font-display: var(--font-display);
+ --font-sans: var(--font-ui);
+
+ /* Semantic colors for easier migration from slate/gray */
+ --color-muted: var(--subtitle-color);
+ --color-accent: var(--button-background-color);
+
+ /* Generated-like palette using color-mix for flexibility */
+ --color-primary-50: color-mix(in oklab, var(--button-background-color) 5%, white);
+ --color-primary-100: color-mix(in oklab, var(--button-background-color) 10%, white);
+ --color-primary-200: color-mix(in oklab, var(--button-background-color) 20%, white);
+ --color-primary-300: color-mix(in oklab, var(--button-background-color) 40%, white);
+ --color-primary-400: color-mix(in oklab, var(--button-background-color) 60%, white);
+ --color-primary-500: var(--button-background-color);
+ --color-primary-600: color-mix(in oklab, var(--button-background-color) 80%, black);
+ --color-primary-700: color-mix(in oklab, var(--button-background-color) 60%, black);
+ --color-primary-800: color-mix(in oklab, var(--button-background-color) 40%, black);
+ --color-primary-900: color-mix(in oklab, var(--button-background-color) 20%, black);
+}
+
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));