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/stop/StopUsageChart.tsx | 143 +++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/frontend/app/components/stop/StopUsageChart.tsx (limited to 'src/frontend/app/components/stop/StopUsageChart.tsx') 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(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 ( +
+
+

+ {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; + 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." + )} +

+
+
+ ); +}; -- cgit v1.3