From 5288cfbed34f94c4321b8d9dc497cfd0da3ffd26 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 8 Mar 2026 23:42:39 +0100 Subject: Refactor VigoUsageProcessor to remove CSV whitelist loading; add StopUsageChart component for usage visualization in Stops page --- .../app/components/arrivals/ArrivalList.tsx | 2 +- src/frontend/app/components/layout/Header.tsx | 5 +- .../app/components/stop/StopUsageChart.tsx | 143 ++++++++++++++++++++ .../app/components/stop/StopUsageModal.tsx | 130 +----------------- src/frontend/app/contexts/PageTitleContext.tsx | 38 +++++- src/frontend/app/routes/stops-$id.css | 3 +- src/frontend/app/routes/stops-$id.tsx | 147 ++++++++++----------- 7 files changed, 252 insertions(+), 216 deletions(-) create mode 100644 src/frontend/app/components/stop/StopUsageChart.tsx (limited to 'src/frontend/app') 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 = ({ const clickable = Boolean(onArrivalClick); return ( -
+
{arrivals.length === 0 && (
{/* 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 = ({ onMenuClick, className = "", }) => { - const { onBack, backTo, titleNode } = usePageTitleContext(); + const { onBack, backTo, titleNode, rightNode } = usePageTitleContext(); return (
@@ -41,7 +41,8 @@ export const Header: React.FC = ({ )} {titleNode ? titleNode :

{title}

}
-
+
+ {rightNode} + ))} +
+
+ +
+
+ {/* Horizontal grid lines */} +
+
+
+
+
+ + {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 ( +
+
+ {data.t > 0 && ( +
+ {data.t} +
+ )} +
+ {data.h % 4 === 0 && ( + + {data.h}h + + )} +
+ ); + })} +
+
{/* Spacer for hour labels */} +
+ +
+

+ {t( + "stop.usage_disclaimer", + "Datos históricos aproximados de ocupación." + )} +

+
+
+ ); +}; 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(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 ( - -
-
-

- {t("stop.usage_title", "Ocupación por horas")} -

-
- {days.map((day) => ( - - ))} -
-
- -
-
- {/* Horizontal grid lines */} -
-
-
-
-
- - {filteredData.map((data) => { - const height = getScaledHeight(data.t); - const isServiceHour = data.h >= 7 && data.h < 23; - - return ( -
-
- {data.t > 0 && ( -
- {data.t} {t("stop.usage_passengers", "pas.")} -
- )} -
- {data.h % 4 === 0 && ( - - {data.h}h - - )} -
- ); - })} -
-
{/* Spacer for hour labels */} -
- -
-

- {t( - "stop.usage_scale_info", - "Escala no lineal para resaltar valores bajos" - )} -

-

- {t( - "stop.usage_disclaimer", - "Datos históricos aproximados de ocupación." - )} -

-
-
+ +
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( @@ -22,6 +24,9 @@ export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({ const [titleNode, setTitleNodeState] = useState( undefined ); + const [rightNode, setRightNodeState] = useState( + 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( + + ); + 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 ( -
+
{apiRoutes.length > 0 && (
{apiRoutes.map((line) => ( @@ -166,9 +173,7 @@ export default function Estimates() {
)} - {/*{stopData && }*/} - -
+
{dataLoading ? ( <>{/*TODO: New loading skeleton*/} ) : dataError ? ( @@ -182,54 +187,40 @@ export default function Estimates() { /> ) : data ? ( <> -
-
- - - {data.usage && data.usage.length > 0 && ( - setIsUsageVisible(!isUsageVisible)} - /> - )} - - setIsHelpModalOpen(true)} - /> -
+
+
+
+ {t("estimates.caption", "Estimaciones a las {{time}}", { + time: dataDate?.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + })} +
-
- {t( - "estimates.caption", - "Estimaciones de llegadas a las {{time}}", - { - time: dataDate?.toLocaleTimeString(), - } - )} -
- -
- {isReducedView ? ( - setIsReducedView(false)} - /> - ) : ( - setIsReducedView(true)} - /> - )} +
+ + {isReducedView ? ( + + ) : ( + + )} +
+ + {data.usage && data.usage.length > 0 && ( +
+ +
+ )} ) : null}
@@ -271,14 +268,6 @@ export default function Estimates() { isOpen={isHelpModalOpen} onClose={() => setIsHelpModalOpen(false)} /> - - {data?.usage && ( - setIsUsageVisible(false)} - usage={data.usage} - /> - )}
); -- cgit v1.3