summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-08 23:42:39 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-08 23:42:39 +0100
commit5288cfbed34f94c4321b8d9dc497cfd0da3ffd26 (patch)
treefc4038fa829a07e58944d32b4b34f626b935e686
parent4056bc1b66db722bfcffaa960f8ff89150971a4d (diff)
Refactor VigoUsageProcessor to remove CSV whitelist loading; add StopUsageChart component for usage visualization in Stops page
-rw-r--r--src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv60
-rw-r--r--src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs54
-rw-r--r--src/frontend/app/components/arrivals/ArrivalList.tsx2
-rw-r--r--src/frontend/app/components/layout/Header.tsx5
-rw-r--r--src/frontend/app/components/stop/StopUsageChart.tsx143
-rw-r--r--src/frontend/app/components/stop/StopUsageModal.tsx130
-rw-r--r--src/frontend/app/contexts/PageTitleContext.tsx38
-rw-r--r--src/frontend/app/routes/stops-$id.css3
-rw-r--r--src/frontend/app/routes/stops-$id.tsx147
9 files changed, 252 insertions, 330 deletions
diff --git a/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv b/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv
deleted file mode 100644
index 8091fd9..0000000
--- a/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv
+++ /dev/null
@@ -1,60 +0,0 @@
-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/Services/Processors/VigoUsageProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs
index 7f98fff..282f08a 100644
--- a/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs
+++ b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs
@@ -1,8 +1,6 @@
using System.Text.Json;
-using CsvHelper;
using Enmarcha.Backend.Types.Arrivals;
using Microsoft.Extensions.Caching.Memory;
-using System.Globalization;
namespace Enmarcha.Backend.Services.Processors;
@@ -11,69 +9,18 @@ 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)
@@ -81,7 +28,6 @@ public class VigoUsageProcessor : IArrivalsProcessor
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))
diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx
index ea01695..83eb4f0 100644
--- a/src/frontend/app/components/arrivals/ArrivalList.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalList.tsx
@@ -17,7 +17,7 @@ export const ArrivalList: React.FC<ArrivalListProps> = ({
const clickable = Boolean(onArrivalClick);
return (
- <div className="flex flex-col gap-3">
+ <div className="flex flex-col flex-1 gap-3">
{arrivals.length === 0 && (
<div className="text-center text-muted mt-16">
{/* TOOD i18n */}
diff --git a/src/frontend/app/components/layout/Header.tsx b/src/frontend/app/components/layout/Header.tsx
index 4378e59..058c18c 100644
--- a/src/frontend/app/components/layout/Header.tsx
+++ b/src/frontend/app/components/layout/Header.tsx
@@ -14,7 +14,7 @@ export const Header: React.FC<HeaderProps> = ({
onMenuClick,
className = "",
}) => {
- const { onBack, backTo, titleNode } = usePageTitleContext();
+ const { onBack, backTo, titleNode, rightNode } = usePageTitleContext();
return (
<header className={`app-header ${className}`}>
@@ -41,7 +41,8 @@ export const Header: React.FC<HeaderProps> = ({
)}
{titleNode ? titleNode : <h1 className="app-header__title">{title}</h1>}
</div>
- <div className="app-header__right">
+ <div className="app-header__right flex items-center gap-2">
+ {rightNode}
<button
className="app-header__menu-btn"
onClick={onMenuClick}
diff --git a/src/frontend/app/components/stop/StopUsageChart.tsx b/src/frontend/app/components/stop/StopUsageChart.tsx
new file mode 100644
index 0000000..ab4b46c
--- /dev/null
+++ b/src/frontend/app/components/stop/StopUsageChart.tsx
@@ -0,0 +1,143 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import type { BusStopUsagePoint } from "~/api/schema";
+
+interface StopUsageChartProps {
+ usage: BusStopUsagePoint[];
+}
+
+export const StopUsageChart = ({ usage }: StopUsageChartProps) => {
+ 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 currentHour = new Date().getHours();
+
+ const [selectedDay, setSelectedDay] = useState<number>(initialDay);
+
+ const days = [
+ { id: 1, label: t("days.monday", "L") },
+ { id: 2, label: t("days.tuesday", "M") },
+ { id: 3, label: t("days.wednesday", "X") },
+ { id: 4, label: t("days.thursday", "J") },
+ { id: 5, label: t("days.friday", "V") },
+ { id: 6, label: t("days.saturday", "S") },
+ { id: 7, label: t("days.sunday", "D") },
+ ];
+
+ 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 linear scale
+ const getScaledHeight = (value: number) => {
+ if (value <= 0) return 0;
+ return (value / maxUsage) * 100;
+ };
+
+ if (!usage || usage.length === 0) return null;
+
+ return (
+ <div className="flex flex-col gap-2 text-slate-900 dark:text-slate-100">
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1.5">
+ <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 overflow-x-auto">
+ {days.map((day) => (
+ <button
+ key={day.id}
+ type="button"
+ onClick={() => setSelectedDay(day.id)}
+ className={`h-7 min-w-7 px-2 rounded-full flex items-center justify-center transition-colors text-xs font-bold shrink-0 ${
+ selectedDay === day.id
+ ? "bg-primary text-white"
+ : "text-muted hover:text-text"
+ }`}
+ >
+ {day.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div className="p-4 sm:p-6 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-border/50">
+ <div className="h-32 sm:h-48 flex items-end gap-1 sm:gap-1.5 px-0 sm: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;
+ const isCurrentHour =
+ selectedDay === initialDay && data.h === currentHour;
+
+ let barBg =
+ "bg-slate-300 dark:bg-slate-700 group-hover:bg-slate-400";
+ if (isCurrentHour) {
+ barBg =
+ "bg-red-500 group-hover:bg-red-600 shadow-[0_0_10px_rgba(239,68,68,0.4)]";
+ } else if (isServiceHour) {
+ barBg =
+ "bg-primary/60 group-hover:bg-primary shadow-[0_0_10px_rgba(var(--primary-rgb),0.2)]";
+ }
+
+ return (
+ <div
+ key={data.h}
+ tabIndex={0}
+ className="group relative flex-1 flex flex-col items-center h-full justify-end cursor-pointer outline-none"
+ >
+ <div
+ className={`w-full rounded-t-md transition-all duration-500 ease-out min-h-[2px] ${barBg}`}
+ 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 group-focus:opacity-100 group-active:opacity-100 transition-opacity whitespace-nowrap z-10 shadow-lg border border-white/10 pointer-events-none">
+ {data.t}
+ </div>
+ )}
+ </div>
+ {data.h % 4 === 0 && (
+ <span
+ className={`text-[10px] font-medium mt-2 absolute -bottom-6 ${isCurrentHour ? "text-red-500 font-bold" : "text-slate-500"}`}
+ >
+ {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-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>
+ );
+};
diff --git a/src/frontend/app/components/stop/StopUsageModal.tsx b/src/frontend/app/components/stop/StopUsageModal.tsx
index 41db64a..0b0cff3 100644
--- a/src/frontend/app/components/stop/StopUsageModal.tsx
+++ b/src/frontend/app/components/stop/StopUsageModal.tsx
@@ -1,7 +1,6 @@
-import { useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
import { Sheet } from "react-modal-sheet";
import type { BusStopUsagePoint } from "~/api/schema";
+import { StopUsageChart } from "./StopUsageChart";
interface StopUsageModalProps {
isOpen: boolean;
@@ -14,135 +13,12 @@ export const StopUsageModal = ({
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 className="p-6 pb-12">
+ <StopUsageChart usage={usage} />
</Sheet.Content>
</Sheet.Container>
<Sheet.Backdrop onTap={onClose} />
diff --git a/src/frontend/app/contexts/PageTitleContext.tsx b/src/frontend/app/contexts/PageTitleContext.tsx
index 8610518..ad41dab 100644
--- a/src/frontend/app/contexts/PageTitleContext.tsx
+++ b/src/frontend/app/contexts/PageTitleContext.tsx
@@ -4,11 +4,13 @@ interface PageTitleContextProps {
title: string;
setTitle: (title: string) => void;
titleNode?: React.ReactNode;
+ rightNode?: React.ReactNode;
onBack?: () => void;
backTo?: string;
setOnBack: (callback?: () => void) => void;
setBackTo: (to?: string) => void;
setTitleNode: (node?: React.ReactNode) => void;
+ setRightNode: (node?: React.ReactNode) => void;
}
const PageTitleContext = createContext<PageTitleContextProps | undefined>(
@@ -22,6 +24,9 @@ export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({
const [titleNode, setTitleNodeState] = useState<React.ReactNode | undefined>(
undefined
);
+ const [rightNode, setRightNodeState] = useState<React.ReactNode | undefined>(
+ undefined
+ );
const [onBack, setOnBackState] = useState<(() => void) | undefined>(
undefined
);
@@ -36,7 +41,11 @@ export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({
};
const setTitleNode = (node?: React.ReactNode) => {
- setTitleNodeState(node);
+ setTitleNodeState((prev) => (prev === node ? prev : node));
+ };
+
+ const setRightNode = (node?: React.ReactNode) => {
+ setRightNodeState((prev) => (prev === node ? prev : node));
};
return (
@@ -45,11 +54,13 @@ export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({
title,
setTitle,
titleNode,
+ rightNode,
onBack,
backTo,
setOnBack,
setBackTo,
setTitleNode,
+ setRightNode,
}}
>
{children}
@@ -75,7 +86,7 @@ export const usePageTitle = (title: string) => {
document.title = `${title} - EnMarcha`;
return () => {};
- }, [title, setTitle]);
+ }, [title]);
};
export const useBackButton = (options?: {
@@ -84,15 +95,18 @@ export const useBackButton = (options?: {
}) => {
const { setOnBack, setBackTo } = usePageTitleContext();
+ const onBack = options?.onBack;
+ const to = options?.to;
+
useEffect(() => {
- setOnBack(options?.onBack);
- setBackTo(options?.to);
+ setOnBack(onBack);
+ setBackTo(to);
return () => {
setOnBack(undefined);
setBackTo(undefined);
};
- }, [options?.onBack, options?.to, setOnBack, setBackTo]);
+ }, [onBack, to]);
};
export const usePageTitleNode = (node?: React.ReactNode) => {
@@ -104,5 +118,17 @@ export const usePageTitleNode = (node?: React.ReactNode) => {
return () => {
setTitleNode(undefined);
};
- }, [node, setTitleNode]);
+ }, []); // Only set on mount/unmount to avoid loops with JSX
+};
+
+export const usePageRightNode = (node: React.ReactNode) => {
+ const { setRightNode } = usePageTitleContext();
+
+ useEffect(() => {
+ setRightNode(node);
+
+ return () => {
+ setRightNode(undefined);
+ };
+ }, []); // Only set on mount/unmount to avoid loops with JSX
};
diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css
index 583b5b9..0420b0e 100644
--- a/src/frontend/app/routes/stops-$id.css
+++ b/src/frontend/app/routes/stops-$id.css
@@ -13,7 +13,6 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
- margin-block: 0 1rem;
}
.table {
@@ -51,6 +50,8 @@
box-sizing: border-box;
gap: 1rem;
+
+ min-height: 100%;
}
.star-icon,
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 3198fca..a61d019 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -1,10 +1,4 @@
-import {
- ChartNoAxesColumn,
- CircleHelp,
- Eye,
- EyeClosed,
- Star,
-} from "lucide-react";
+import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
@@ -21,8 +15,8 @@ import { PullToRefresh } from "~/components/PullToRefresh";
import RouteIcon from "~/components/RouteIcon";
import { StopHelpModal } from "~/components/stop/StopHelpModal";
import { StopMapModal } from "~/components/stop/StopMapModal";
-import { StopUsageModal } from "~/components/stop/StopUsageModal";
-import { usePageTitle } from "~/contexts/PageTitleContext";
+import { StopUsageChart } from "~/components/stop/StopUsageChart";
+import { usePageRightNode, usePageTitle } from "~/contexts/PageTitleContext";
import { formatHex } from "~/utils/colours";
import StopDataProvider from "../data/StopDataProvider";
import "../tailwind-full.css";
@@ -58,7 +52,6 @@ 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
@@ -72,6 +65,30 @@ export default function Estimates() {
usePageTitle(getStopDisplayName());
+ const toggleFavourite = useCallback(() => {
+ if (favourited) {
+ StopDataProvider.removeFavourite(stopId);
+ setFavourited(false);
+ } else {
+ StopDataProvider.addFavourite(stopId);
+ setFavourited(true);
+ }
+ }, [favourited, stopId]);
+
+ usePageRightNode(
+ <button
+ onClick={toggleFavourite}
+ className={`app-header__menu-btn p-2 rounded-full transition-colors ${
+ favourited
+ ? "text-[var(--star-color)]"
+ : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
+ }`}
+ aria-label={t("stop.toggle_favourite", "Alternar favorito")}
+ >
+ <Star className={favourited ? "fill-current" : ""} size={24} />
+ </button>
+ );
+
const parseError = (error: any): ErrorInfo => {
if (!navigator.onLine) {
return { type: "network", message: "No internet connection" };
@@ -138,19 +155,9 @@ export default function Estimates() {
setDataLoading(false);
}, [stopId, loadData]);
- const toggleFavourite = () => {
- if (favourited) {
- StopDataProvider.removeFavourite(stopId);
- setFavourited(false);
- } else {
- StopDataProvider.addFavourite(stopId);
- setFavourited(true);
- }
- };
-
return (
<PullToRefresh onRefresh={handleManualRefresh}>
- <div className="page-container stops-page">
+ <div className="page-container stops-page flex-1">
{apiRoutes.length > 0 && (
<div className={`estimates-lines-container scrollable`}>
{apiRoutes.map((line) => (
@@ -166,9 +173,7 @@ export default function Estimates() {
</div>
)}
- {/*{stopData && <StopAlert stop={stopData} />}*/}
-
- <div className="estimates-list-container">
+ <div className="estimates-list-container flex-1">
{dataLoading ? (
<>{/*TODO: New loading skeleton*/}</>
) : dataError ? (
@@ -182,54 +187,40 @@ export default function Estimates() {
/>
) : data ? (
<>
- <div className="flex items-center justify-between py-2">
- <div className="flex items-center gap-4">
- <Star
- className={`cursor-pointer transition-colors ${
- favourited
- ? "fill-[var(--star-color)] text-[var(--star-color)]"
- : "text-muted"
- }`}
- 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)}
- />
- </div>
+ <div className="flex flex-col gap-3">
+ <div className="flex items-center justify-between">
+ <div className="consolidated-circulation-caption text-xs font-bold uppercase tracking-wider text-muted m-0">
+ {t("estimates.caption", "Estimaciones a las {{time}}", {
+ time: dataDate?.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ })}
+ </div>
- <div className="consolidated-circulation-caption">
- {t(
- "estimates.caption",
- "Estimaciones de llegadas a las {{time}}",
- {
- time: dataDate?.toLocaleTimeString(),
- }
- )}
- </div>
-
- <div>
- {isReducedView ? (
- <EyeClosed
- className="text-muted"
- onClick={() => setIsReducedView(false)}
- />
- ) : (
- <Eye
- className="text-muted"
- onClick={() => setIsReducedView(true)}
- />
- )}
+ <div className="flex items-center gap-2">
+ <button
+ className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
+ onClick={() => setIsHelpModalOpen(true)}
+ >
+ <CircleHelp className="w-5 h-5" />
+ </button>
+ {isReducedView ? (
+ <button
+ className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
+ onClick={() => setIsReducedView(false)}
+ >
+ <EyeClosed className="w-5 h-5" />
+ </button>
+ ) : (
+ <button
+ className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
+ onClick={() => setIsReducedView(true)}
+ >
+ <Eye className="w-5 h-5" />
+ </button>
+ )}
+ </div>
</div>
</div>
<ArrivalList
@@ -240,6 +231,12 @@ export default function Estimates() {
setIsMapModalOpen(true);
}}
/>
+
+ {data.usage && data.usage.length > 0 && (
+ <div className="mt-8">
+ <StopUsageChart usage={data.usage} />
+ </div>
+ )}
</>
) : null}
</div>
@@ -271,14 +268,6 @@ export default function Estimates() {
isOpen={isHelpModalOpen}
onClose={() => setIsHelpModalOpen(false)}
/>
-
- {data?.usage && (
- <StopUsageModal
- isOpen={isUsageVisible}
- onClose={() => setIsUsageVisible(false)}
- usage={data.usage}
- />
- )}
</div>
</PullToRefresh>
);