diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-08 23:42:39 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-08 23:42:39 +0100 |
| commit | 5288cfbed34f94c4321b8d9dc497cfd0da3ffd26 (patch) | |
| tree | fc4038fa829a07e58944d32b4b34f626b935e686 | |
| parent | 4056bc1b66db722bfcffaa960f8ff89150971a4d (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.csv | 60 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs | 54 | ||||
| -rw-r--r-- | src/frontend/app/components/arrivals/ArrivalList.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/Header.tsx | 5 | ||||
| -rw-r--r-- | src/frontend/app/components/stop/StopUsageChart.tsx | 143 | ||||
| -rw-r--r-- | src/frontend/app/components/stop/StopUsageModal.tsx | 130 | ||||
| -rw-r--r-- | src/frontend/app/contexts/PageTitleContext.tsx | 38 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.css | 3 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 147 |
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> ); |
