aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-02-14 01:35:54 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-02-14 01:36:09 +0100
commitac366d04cd54869c9a2b090aae24a276c32a85a6 (patch)
tree64a5c5903b07646a5c58b1b7e4c9704022549245 /src
parent3f8fb6fda07f97c9fd676cff67c637c0df0f7029 (diff)
feat: Implemen experimental bus stop usage display
Diffstat (limited to 'src')
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs9
-rw-r--r--src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv60
-rw-r--r--src/Enmarcha.Backend/Enmarcha.Backend.csproj2
-rw-r--r--src/Enmarcha.Backend/Program.cs1
-rw-r--r--src/Enmarcha.Backend/Services/ArrivalsPipeline.cs1
-rw-r--r--src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs121
-rw-r--r--src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs15
-rw-r--r--src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs3
-rw-r--r--src/frontend/app/api/schema.ts12
-rw-r--r--src/frontend/app/components/stop/StopUsageModal.tsx151
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json15
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json15
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json15
-rw-r--r--src/frontend/app/routes/map.tsx12
-rw-r--r--src/frontend/app/routes/stops-$id.tsx43
-rw-r--r--src/frontend/public/pwa-worker.js11
16 files changed, 470 insertions, 16 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index 13fb430..a887c89 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -130,7 +130,7 @@ public partial class ArrivalsController : ControllerBase
arrivals.Add(arrival);
}
- await _pipeline.ExecuteAsync(new ArrivalsContext
+ var context = new ArrivalsContext
{
StopId = id,
StopCode = stop.Code,
@@ -138,7 +138,9 @@ public partial class ArrivalsController : ControllerBase
Arrivals = arrivals,
NowLocal = nowLocal,
StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon }
- });
+ };
+
+ await _pipeline.ExecuteAsync(context);
var feedId = id.Split(':')[0];
@@ -167,7 +169,8 @@ public partial class ArrivalsController : ControllerBase
ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
r.TextColor
})],
- Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)]
+ Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)],
+ Usage = context.Usage
});
}
diff --git a/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv b/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv
new file mode 100644
index 0000000..8091fd9
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv
@@ -0,0 +1,60 @@
+codigo
+14264
+8630
+6940
+14206
+14333
+8750
+5610
+6930
+8610
+8840
+5800
+1310
+5820
+5630
+6300
+2780
+8820
+8460
+8520
+8540
+8470
+14892
+8040
+8480
+6570
+8450
+6960
+5300
+6860
+5790
+5540
+20102
+6620
+14337
+5650
+1360
+20193
+8500
+1920
+20111
+20075
+5560
+5570
+7000
+5530
+2820
+5620
+5680
+5520
+6550
+1260
+1280
+8770
+2830
+14123
+14163
+14168
+2020
+8060
diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
index 1591e7c..de6489e 100644
--- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj
+++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
@@ -35,7 +35,7 @@
</ItemGroup>
<ItemGroup>
- <None Update="Data\xunta_fares.csv">
+ <None Update="Data\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs
index 8599a5c..f4b39ff 100644
--- a/src/Enmarcha.Backend/Program.cs
+++ b/src/Enmarcha.Backend/Program.cs
@@ -128,6 +128,7 @@ builder.Services.AddSingleton<LineFormatterService>();
builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, SantiagoRealTimeProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, VigoUsageProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, FilterAndSortProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, NextStopsProcessor>();
diff --git a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs
index 4f49afe..6d8c2c0 100644
--- a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs
+++ b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs
@@ -23,6 +23,7 @@ public class ArrivalsContext
public Position? StopLocation { get; set; }
public required List<Arrival> Arrivals { get; set; }
+ public List<BusStopUsagePoint>? Usage { get; set; }
public required DateTime NowLocal { get; set; }
}
diff --git a/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs
new file mode 100644
index 0000000..f5c7664
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+using CsvHelper;
+using CsvHelper.Configuration;
+using Enmarcha.Backend.Types.Arrivals;
+using Microsoft.Extensions.Caching.Memory;
+using System.Globalization;
+
+namespace Enmarcha.Backend.Services.Processors;
+
+public class VigoUsageProcessor : IArrivalsProcessor
+{
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<VigoUsageProcessor> _logger;
+ private readonly IHostEnvironment _environment;
+ private readonly FeedService _feedService;
+ private static readonly HashSet<string> _vigoStopsWhitelist = [];
+ private static bool _whitelistLoaded = false;
+ private static readonly object _lock = new();
+
+ public VigoUsageProcessor(
+ HttpClient httpClient,
+ IMemoryCache cache,
+ ILogger<VigoUsageProcessor> logger,
+ IHostEnvironment environment,
+ FeedService feedService)
+ {
+ _httpClient = httpClient;
+ _cache = cache;
+ _logger = logger;
+ _environment = environment;
+ _feedService = feedService;
+
+ LoadWhitelist();
+ }
+
+ private void LoadWhitelist()
+ {
+ if (_whitelistLoaded) return;
+
+ lock (_lock)
+ {
+ if (_whitelistLoaded) return;
+
+ try
+ {
+ var path = Path.Combine(_environment.ContentRootPath, "Data", "vitrasa_stops_p95.csv");
+ if (File.Exists(path))
+ {
+ using var reader = new StreamReader(path);
+ using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
+ csv.Read();
+ csv.ReadHeader();
+ while (csv.Read())
+ {
+ var code = csv.GetField("codigo");
+ if (!string.IsNullOrWhiteSpace(code))
+ {
+ _vigoStopsWhitelist.Add(code.Trim());
+ }
+ }
+ _logger.LogInformation("Loaded {Count} Vigo stops for usage data whitelist", _vigoStopsWhitelist.Count);
+ }
+ else
+ {
+ _logger.LogWarning("Vigo stops whitelist CSV not found at {Path}", path);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error loading Vigo stops whitelist");
+ }
+ finally
+ {
+ _whitelistLoaded = true;
+ }
+ }
+ }
+
+ public async Task ProcessAsync(ArrivalsContext context)
+ {
+ if (!context.StopId.StartsWith("vitrasa:")) return;
+
+ var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode);
+ if (!_vigoStopsWhitelist.Contains(normalizedCode)) return;
+
+ var cacheKey = $"vigo_usage_{normalizedCode}";
+ if (_cache.TryGetValue(cacheKey, out List<BusStopUsagePoint>? cachedUsage))
+ {
+ context.Usage = cachedUsage;
+ return;
+ }
+
+ try
+ {
+ using var activity = Telemetry.Source.StartActivity("FetchVigoUsage");
+ var url = $"https://datos.vigo.org/vci_api_app/api2.jsp?tipo=TRANSPORTE_PARADA_HORAS_USO&parada={normalizedCode}";
+ var response = await _httpClient.GetAsync(url);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var json = await response.Content.ReadAsStringAsync();
+ var usage = JsonSerializer.Deserialize<List<BusStopUsagePoint>>(json);
+
+ if (usage != null)
+ {
+ _cache.Set(cacheKey, usage, TimeSpan.FromDays(7));
+ context.Usage = usage;
+ }
+ }
+ else
+ {
+ _logger.LogWarning("Failed to fetch usage data for stop {StopCode}, status: {Status}", normalizedCode, response.StatusCode);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching usage data for Vigo stop {StopCode}", normalizedCode);
+ }
+ }
+}
diff --git a/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs b/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs
new file mode 100644
index 0000000..edb08f4
--- /dev/null
+++ b/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace Enmarcha.Backend.Types.Arrivals;
+
+public class BusStopUsagePoint
+{
+ [JsonPropertyName("h")]
+ public required int Hour { get; set; }
+
+ [JsonPropertyName("t")]
+ public required int Total { get; set; }
+
+ [JsonPropertyName("d")]
+ public required int DayOfWeek { get; set; }
+}
diff --git a/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs b/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs
index 4d2f481..ddc4535 100644
--- a/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs
+++ b/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs
@@ -18,4 +18,7 @@ public class StopArrivalsResponse
[JsonPropertyName("arrivals")]
public List<Arrival> Arrivals { get; set; } = [];
+
+ [JsonPropertyName("usage")]
+ public List<BusStopUsagePoint>? Usage { get; set; }
}
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index d9aec89..c0c97a4 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -36,8 +36,14 @@ export const ShiftBadgeSchema = z.object({
export const PositionSchema = z.object({
latitude: z.number(),
longitude: z.number(),
- orientationDegrees: z.number(),
- shapeIndex: z.number(),
+ orientationDegrees: z.number().optional().nullable(),
+ shapeIndex: z.number().optional().nullable(),
+});
+
+export const BusStopUsagePointSchema = z.object({
+ h: z.number().int(),
+ t: z.number().int(),
+ d: z.number().int(),
});
export const VehicleInformationSchema = z.object({
@@ -67,6 +73,7 @@ export const StopArrivalsResponseSchema = z.object({
stopLocation: PositionSchema.optional().nullable(),
routes: z.array(RouteInfoSchema),
arrivals: z.array(ArrivalSchema),
+ usage: z.array(BusStopUsagePointSchema).optional().nullable(),
});
export type RouteInfo = z.infer<typeof RouteInfoSchema>;
@@ -77,6 +84,7 @@ export type DelayBadge = z.infer<typeof DelayBadgeSchema>;
export type ShiftBadge = z.infer<typeof ShiftBadgeSchema>;
export type Position = z.infer<typeof PositionSchema>;
export type Arrival = z.infer<typeof ArrivalSchema>;
+export type BusStopUsagePoint = z.infer<typeof BusStopUsagePointSchema>;
export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>;
// Transit Routes
diff --git a/src/frontend/app/components/stop/StopUsageModal.tsx b/src/frontend/app/components/stop/StopUsageModal.tsx
new file mode 100644
index 0000000..41db64a
--- /dev/null
+++ b/src/frontend/app/components/stop/StopUsageModal.tsx
@@ -0,0 +1,151 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Sheet } from "react-modal-sheet";
+import type { BusStopUsagePoint } from "~/api/schema";
+
+interface StopUsageModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ usage: BusStopUsagePoint[];
+}
+
+export const StopUsageModal = ({
+ isOpen,
+ onClose,
+ usage,
+}: StopUsageModalProps) => {
+ const { t } = useTranslation();
+
+ // Get current day of week (1=Monday, 7=Sunday)
+ // JS getDay(): 0=Sunday, 1=Monday ... 6=Saturday
+ const currentJsDay = new Date().getDay();
+ const initialDay = currentJsDay === 0 ? 7 : currentJsDay;
+
+ const [selectedDay, setSelectedDay] = useState<number>(initialDay);
+
+ const days = [
+ { id: 1, label: t("days.monday") },
+ { id: 2, label: t("days.tuesday") },
+ { id: 3, label: t("days.wednesday") },
+ { id: 4, label: t("days.thursday") },
+ { id: 5, label: t("days.friday") },
+ { id: 6, label: t("days.saturday") },
+ { id: 7, label: t("days.sunday") },
+ ];
+
+ const filteredData = useMemo(() => {
+ const data = usage.filter((u) => u.d === selectedDay);
+ // Ensure all 24 hours are represented
+ const fullDay = Array.from({ length: 24 }, (_, h) => {
+ const match = data.find((u) => u.h === h);
+ return { h, t: match?.t ?? 0 };
+ });
+ return fullDay;
+ }, [usage, selectedDay]);
+
+ const maxUsage = useMemo(() => {
+ const max = Math.max(...filteredData.map((d) => d.t));
+ return max === 0 ? 1 : max;
+ }, [filteredData]);
+
+ // Use a square root scale to make smaller values more visible
+ const getScaledHeight = (value: number) => {
+ if (value <= 0) return 0;
+ const scaledValue = Math.sqrt(value);
+ const scaledMax = Math.sqrt(maxUsage);
+ return (scaledValue / scaledMax) * 100;
+ };
+
+ return (
+ <Sheet isOpen={isOpen} onClose={onClose} detent="content">
+ <Sheet.Container className="bg-white! dark:bg-black! !rounded-t-[20px]">
+ <Sheet.Header className="bg-white! dark:bg-black! !rounded-t-[20px]" />
+ <Sheet.Content className="p-6 pb-12 text-slate-900 dark:text-slate-100">
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
+ <h2 className="text-xl font-bold">
+ {t("stop.usage_title", "Ocupación por horas")}
+ </h2>
+ <div className="flex items-center gap-1 rounded-full border border-border bg-background/90 p-1 shadow-sm backdrop-blur w-fit">
+ {days.map((day) => (
+ <button
+ key={day.id}
+ type="button"
+ onClick={() => setSelectedDay(day.id)}
+ className={`h-8 min-w-8 px-2 rounded-full flex items-center justify-center transition-colors text-xs font-bold ${
+ selectedDay === day.id
+ ? "bg-primary text-white"
+ : "text-muted hover:text-text"
+ }`}
+ >
+ {day.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div className="p-6 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-border/50">
+ <div className="h-64 flex items-end gap-1.5 px-1 relative">
+ {/* Horizontal grid lines */}
+ <div className="absolute inset-0 flex flex-col justify-between pointer-events-none opacity-20 py-1">
+ <div className="w-full border-t border-slate-400 border-dashed"></div>
+ <div className="w-full border-t border-slate-400 border-dashed"></div>
+ <div className="w-full border-t border-slate-400 border-dashed"></div>
+ </div>
+
+ {filteredData.map((data) => {
+ const height = getScaledHeight(data.t);
+ const isServiceHour = data.h >= 7 && data.h < 23;
+
+ return (
+ <div
+ key={data.h}
+ className="group relative flex-1 flex flex-col items-center h-full justify-end"
+ >
+ <div
+ className={`w-full rounded-t-md transition-all duration-500 ease-out min-h-[2px] ${
+ isServiceHour
+ ? "bg-primary/60 group-hover:bg-primary shadow-[0_0_10px_rgba(var(--primary-rgb),0.2)]"
+ : "bg-slate-300 dark:bg-slate-700 group-hover:bg-slate-400"
+ }`}
+ style={{ height: `${height}%` }}
+ >
+ {data.t > 0 && (
+ <div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 shadow-lg border border-white/10">
+ {data.t} {t("stop.usage_passengers", "pas.")}
+ </div>
+ )}
+ </div>
+ {data.h % 4 === 0 && (
+ <span className="text-[10px] font-medium text-slate-500 mt-2 absolute -bottom-6">
+ {data.h}h
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ <div className="h-6"></div> {/* Spacer for hour labels */}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <p className="text-[10px] text-slate-400 dark:text-slate-500 text-center uppercase tracking-wider font-semibold">
+ {t(
+ "stop.usage_scale_info",
+ "Escala no lineal para resaltar valores bajos"
+ )}
+ </p>
+ <p className="text-xs text-slate-500 dark:text-slate-400 text-center italic leading-relaxed">
+ {t(
+ "stop.usage_disclaimer",
+ "Datos históricos aproximados de ocupación."
+ )}
+ </p>
+ </div>
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop onTap={onClose} />
+ </Sheet>
+ );
+};
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index f3d15b3..6cb939d 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -222,5 +222,20 @@
"gps_quality": "GPS Quality",
"gps_reliable": "GPS data is from the current trip, and the position is a reliable estimate.",
"gps_imprecise": "GPS data seems to indicate the bus is on the previous trip (possibly from another line). The position might not be reliable."
+ },
+ "days": {
+ "monday": "Mo",
+ "tuesday": "Tu",
+ "wednesday": "We",
+ "thursday": "Th",
+ "friday": "Fr",
+ "saturday": "Sa",
+ "sunday": "Su"
+ },
+ "stop": {
+ "usage_title": "Hourly occupancy",
+ "usage_passengers": "pax",
+ "usage_disclaimer": "Based on average historical occupancy from recent months available at datos.vigo.org. Does not reflect real-time occupancy.",
+ "usage_scale_info": "Graph uses a non-linear scale to better highlight lower occupancy values."
}
}
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index ea74031..5334fe1 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -222,5 +222,20 @@
"gps_quality": "Calidad GPS",
"gps_reliable": "Los datos del GPS son del viaje actual, y la posición es una estimación fiable.",
"gps_imprecise": "Los datos del GPS parecen indicar que el autobús está realizando el viaje anterior (posiblemente de otra línea). La posición puede no ser fiable."
+ },
+ "days": {
+ "monday": "L",
+ "tuesday": "M",
+ "wednesday": "X",
+ "thursday": "J",
+ "friday": "V",
+ "saturday": "S",
+ "sunday": "D"
+ },
+ "stop": {
+ "usage_title": "Ocupación por horas",
+ "usage_passengers": "pas.",
+ "usage_disclaimer": "Basado en la ocupación histórica promedio de los últimos meses disponible en datos.vigo.org. No refleja la ocupación en tiempo real.",
+ "usage_scale_info": "La escala del gráfico no es lineal para resaltar mejor los valores bajos."
}
}
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 7072f55..132ab0b 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -215,5 +215,20 @@
"gps_quality": "Calidade GPS",
"gps_reliable": "Os datos do GPS son da viaxe actual, e a posición é unha estimación fiable.",
"gps_imprecise": "Os datos do GPS parecen indicar que o autobús está a realizar a viaxe anterior (posiblemente doutra liña). A posición pode non ser fiable."
+ },
+ "days": {
+ "monday": "L",
+ "tuesday": "M",
+ "wednesday": "Mc",
+ "thursday": "X",
+ "friday": "V",
+ "saturday": "S",
+ "sunday": "D"
+ },
+ "stop": {
+ "usage_title": "Ocupación por horas",
+ "usage_passengers": "pas.",
+ "usage_disclaimer": "Baseado na ocupación histórica media dos últimos meses dispoñible en datos.vigo.org. Non reflicte a ocupación en tempo real.",
+ "usage_scale_info": "A escala do gráfico non é lineal para resaltar mellor os valores baixos."
}
}
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index c9c4850..2686222 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -307,6 +307,18 @@ export default function StopMap() {
"text-anchor": "center",
"text-justify": "center",
"text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16],
+ "symbol-sort-key": [
+ "match",
+ ["get", "transitKind"],
+ "coach",
+ 3,
+ "train",
+ 2,
+ "bus",
+ 1,
+ 0,
+ ],
+ "text-allow-overlap": false,
}}
paint={{
"text-color": [
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index bff8c7f..8f84764 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -1,18 +1,31 @@
-import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react";
+import {
+ ChartNoAxesColumn,
+ CircleHelp,
+ Eye,
+ EyeClosed,
+ Star,
+} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { fetchArrivals } from "~/api/arrivals";
-import { type Arrival, type Position, type RouteInfo } from "~/api/schema";
+import {
+ type Arrival,
+ type Position,
+ type RouteInfo,
+ type StopArrivalsResponse,
+} from "~/api/schema";
import { ArrivalList } from "~/components/arrivals/ArrivalList";
import { ErrorDisplay } from "~/components/ErrorDisplay";
import LineIcon from "~/components/LineIcon";
import { PullToRefresh } from "~/components/PullToRefresh";
import { StopHelpModal } from "~/components/stop/StopHelpModal";
import { StopMapModal } from "~/components/stop/StopMapModal";
+import { StopUsageModal } from "~/components/stop/StopUsageModal";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { formatHex } from "~/utils/colours";
import StopDataProvider from "../data/StopDataProvider";
+import "../tailwind-full.css";
import "./stops-$id.css";
export const getArrivalId = (a: Arrival): string => {
@@ -36,7 +49,7 @@ export default function Estimates() {
);
// Data state
- const [data, setData] = useState<Arrival[] | null>(null);
+ const [data, setData] = useState<StopArrivalsResponse | null>(null);
const [dataDate, setDataDate] = useState<Date | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const [dataError, setDataError] = useState<ErrorInfo | null>(null);
@@ -45,6 +58,7 @@ export default function Estimates() {
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
const [isMapModalOpen, setIsMapModalOpen] = useState(false);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
+ const [isUsageVisible, setIsUsageVisible] = useState(false);
const [isReducedView, setIsReducedView] = useState(false);
const [selectedArrivalId, setSelectedArrivalId] = useState<
string | undefined
@@ -84,7 +98,7 @@ export default function Estimates() {
setDataError(null);
const response = await fetchArrivals(stopId, false);
- setData(response.arrivals);
+ setData(response);
setStopName(response.stopName);
setApiRoutes(response.routes);
if (response.stopLocation) {
@@ -179,6 +193,15 @@ export default function Estimates() {
onClick={toggleFavourite}
/>
+ {data.usage && data.usage.length > 0 && (
+ <ChartNoAxesColumn
+ className={`cursor-pointer transition-colors ${
+ isUsageVisible ? "text-primary" : "text-muted"
+ }`}
+ onClick={() => setIsUsageVisible(!isUsageVisible)}
+ />
+ )}
+
<CircleHelp
className="text-muted cursor-pointer"
onClick={() => setIsHelpModalOpen(true)}
@@ -210,7 +233,7 @@ export default function Estimates() {
</div>
</div>
<ArrivalList
- arrivals={data}
+ arrivals={data.arrivals}
reduced={isReducedView}
onArrivalClick={(arrival) => {
setSelectedArrivalId(getArrivalId(arrival));
@@ -230,7 +253,7 @@ export default function Estimates() {
longitude: apiLocation?.longitude,
lines: [],
}}
- circulations={(data ?? []).map((a) => ({
+ circulations={(data?.arrivals ?? []).map((a) => ({
id: getArrivalId(a),
currentPosition: a.currentPosition ?? undefined,
stopShapeIndex: a.stopShapeIndex ?? undefined,
@@ -248,6 +271,14 @@ export default function Estimates() {
isOpen={isHelpModalOpen}
onClose={() => setIsHelpModalOpen(false)}
/>
+
+ {data?.usage && (
+ <StopUsageModal
+ isOpen={isUsageVisible}
+ onClose={() => setIsUsageVisible(false)}
+ usage={data.usage}
+ />
+ )}
</div>
</PullToRefresh>
);
diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js
index 0a9be9c..9427bd3 100644
--- a/src/frontend/public/pwa-worker.js
+++ b/src/frontend/public/pwa-worker.js
@@ -1,10 +1,13 @@
-const CACHE_VERSION = "20260101a";
+const CACHE_VERSION = "20260211a";
const STATIC_CACHE_NAME = `static-cache-${CACHE_VERSION}`;
const STATIC_CACHE_ASSETS = [
"/favicon.ico",
- "/icon-square-256.png",
- "/icon-round-256.png",
- "/icon-inverse.png",
+ "/icon-192.png",
+ "/icon-512.png",
+ "/icon-maskable-192.png",
+ "/icon-maskable-512.png",
+ "/icon-monochrome-256.png",
+ "/icon.svg",
];
const EXPR_CACHE_AFTER_FIRST_VIEW = /(\/assets\/.*)/;