From 417930355fbe6089536c60c3ffba75c8691ca581 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 8 Dec 2025 00:33:16 +0100 Subject: feat: add StopHelpModal component and integrate help functionality in Estimates --- src/frontend/app/components/StopHelpModal.tsx | 153 +++++++++++++++++++++ .../Stops/ConsolidatedCirculationCard.tsx | 60 ++++++-- src/frontend/app/i18n/locales/en-GB.json | 27 +++- src/frontend/app/i18n/locales/es-ES.json | 27 +++- src/frontend/app/i18n/locales/gl-ES.json | 27 +++- src/frontend/app/routes/stops-$id.tsx | 21 ++- 6 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 src/frontend/app/components/StopHelpModal.tsx diff --git a/src/frontend/app/components/StopHelpModal.tsx b/src/frontend/app/components/StopHelpModal.tsx new file mode 100644 index 0000000..87e02f9 --- /dev/null +++ b/src/frontend/app/components/StopHelpModal.tsx @@ -0,0 +1,153 @@ +import { AlertTriangle, Clock, LocateIcon } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet } from "react-modal-sheet"; + +interface StopHelpModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const StopHelpModal: React.FC = ({ + isOpen, + onClose, +}) => { + const { t } = useTranslation(); + + return ( + + + + +
+
+

+ {t("stop_help.title")} +

+ +
+
+
+ +
+
+

+ {t("stop_help.realtime_ok")} +

+

+ {t("stop_help.realtime_ok_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.realtime_warning")} +

+

+ {t("stop_help.realtime_warning_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.scheduled")} +

+

+ {t("stop_help.scheduled_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.gps")} +

+

+ {t("stop_help.gps_desc")} +

+
+
+
+
+ +
+

+ {t("stop_help.punctuality")} +

+ +
+
+ + {t("stop_help.punctuality_ontime_label", "En hora")} + +

+ {t("stop_help.punctuality_ontime")} +

+
+ +
+ + {t("stop_help.punctuality_early_label", "Adelanto")} + +

+ {t("stop_help.punctuality_early")} +

+
+ +
+ + {t("stop_help.punctuality_late_label", "Retraso")} + +

+ {t("stop_help.punctuality_late")} +

+
+
+
+ +
+

+ {t("stop_help.gps_quality")} +

+ +
+
+ + {t("stop_help.gps_reliable_label", "GPS fiable")} + +

+ {t("stop_help.gps_reliable")} +

+
+ +
+ + {t("stop_help.gps_imprecise_label", "GPS impreciso")} + +

+ {t("stop_help.gps_imprecise")} +

+
+
+
+
+
+
+ +
+ ); +}; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 8a2eb94..635c0ce 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import LineIcon from "~components/LineIcon"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; +import { AlertTriangle, LocateIcon } from "lucide-react"; import "./ConsolidatedCirculationCard.css"; interface ConsolidatedCirculationCardProps { @@ -119,8 +120,9 @@ export const ConsolidatedCirculationCard: React.FC< // On time if (delta === 0) { return { - label: reduced ? "OK" : t("estimates.delay_on_time", "En hora (0 min)"), + label: reduced ? "OK" : t("estimates.delay_on_time"), tone: "delay-ok", + kind: "delay", } as const; } @@ -129,40 +131,71 @@ export const ConsolidatedCirculationCard: React.FC< const tone = delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical"; return { - label: reduced ? `R${delta}` : t("estimates.delay_positive", "Retraso de {{minutes}} min", { + label: reduced ? `R${delta}` : t("estimates.delay_positive", { minutes: delta, }), tone, + kind: "delay", } as const; } // Early const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; return { - label: reduced ? `A${absDelta}` : t("estimates.delay_negative", "Adelanto de {{minutes}} min", { + label: reduced ? `A${absDelta}` : t("estimates.delay_negative", { minutes: absDelta, }), tone, + kind: "delay", } as const; }, [estimate.schedule, estimate.realTime, t, reduced]); const metaChips = useMemo(() => { - const chips: Array<{ label: string; tone?: string }> = []; + const chips: Array<{ label: string; tone?: string, kind?: "regular" | "gps" | "delay" | "warning" }> = []; + if (delayChip) { chips.push(delayChip); } + if (estimate.schedule) { chips.push({ label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay( estimate.schedule.tripId )}`, + kind: "regular", }); } + if (estimate.realTime && estimate.realTime.distance >= 0) { - chips.push({ label: formatDistance(estimate.realTime.distance) }); + chips.push({ label: formatDistance(estimate.realTime.distance), kind: "regular" }); + } + + if (estimate.currentPosition) { + if (estimate.isPreviousTrip) { + chips.push({ label: t("estimates.previous_trip"), kind: "gps" }); + } else { + chips.push({ label: t("estimates.bus_gps_position"), kind: "gps" }); + } + } + + if (timeClass === "time-delayed") { + chips.push({ + label: reduced ? "!" : t("estimates.low_accuracy"), + tone: "warning", + kind: "warning", + }); } + + if (timeClass === "time-scheduled") { + chips.push({ + label: reduced ? "⧗" : t("estimates.no_realtime"), + tone: "warning", + kind: "warning", + }); + } + return chips; - }, [delayChip, estimate.schedule, estimate.realTime]); + }, [delayChip, estimate.schedule, estimate.realTime, timeClass, t, reduced]); // Check if bus has GPS position (live tracking) const hasGpsPosition = !!estimate.currentPosition; @@ -218,6 +251,9 @@ export const ConsolidatedCirculationCard: React.FC< case "delay-early": chipColourClasses = "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300"; break; + case "warning": + chipColourClasses = "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300"; + break; default: chipColourClasses = "bg-black/[0.06] dark:bg-white/[0.12] text-[var(--text-color)]"; } @@ -227,6 +263,8 @@ export const ConsolidatedCirculationCard: React.FC< key={`${chip.label}-${idx}`} className={`text-xs px-2 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 ${chipColourClasses}`} > + {chip.kind === "gps" && ()} + {chip.kind === "warning" && ()} {chip.label} ); @@ -284,14 +322,6 @@ export const ConsolidatedCirculationCard: React.FC< ); })()} - {hasGpsPosition && ( -
- -
- )}
{etaValue} @@ -307,6 +337,8 @@ export const ConsolidatedCirculationCard: React.FC< key={`${chip.label}-${idx}`} className={`meta-chip ${chip.tone ?? ""}`.trim()} > + {chip.kind === "gps" && ()} + {chip.kind === "warning" && ()} {chip.label} ))} diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 1ac073c..3bbf820 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -63,7 +63,14 @@ "trip": "trip", "last_updated": "Updated at", "reload": "Reload", - "unknown_service": "Unknown service. It may be a reinforcement or the service has a different name than planned." + "unknown_service": "Unknown service. It may be a reinforcement or the service has a different name than planned.", + "delay_on_time": "On time (0 min)", + "delay_positive": "{{minutes}} min late", + "delay_negative": "{{minutes}} min early", + "previous_trip": "Estimated GPS", + "bus_gps_position": "Reliable GPS", + "low_accuracy": "Low accuracy", + "no_realtime": "No real-time" }, "timetable": { "fullCaption": "Theoretical timetables for this stop", @@ -103,5 +110,23 @@ }, "lines": { "description": "Below is a list of Vigo urban bus lines with their respective routes and links to official timetables." + }, + "stop_help": { + "title": "Estimates guide", + "realtime_ok": "Reliable real-time", + "realtime_ok_desc": "The bus is theoretically running, and the estimate is based on reliable real-time data.", + "realtime_warning": "Imprecise estimate", + "realtime_warning_desc": "We have real-time data for a trip that hasn't left the terminus yet. The estimate might be too optimistic, or pessimistic close to departure.", + "scheduled": "Scheduled time", + "scheduled_desc": "No real-time data available. Showing theoretical arrival time based on schedule data. Usually happens at the start of some combined lines or the first trip of the day.", + "gps": "GPS position", + "gps_desc": "Indicates we know the approximate location of the bus, based on the distance in meters reported by the operator and the route we believe it's following.", + "punctuality": "Punctuality", + "punctuality_ontime": "The bus is running on schedule (within a courtesy margin).", + "punctuality_early": "The bus is running early (2 minutes or more).", + "punctuality_late": "The bus is running late (4 minutes or more).", + "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." } } diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index d6facae..e5fa0ad 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -63,7 +63,14 @@ "trip": "viaje", "last_updated": "Actualizado a las", "reload": "Recargar", - "unknown_service": "Servicio desconocido. Puede tratarse de un refuerzo o de que el servicio lleva un nombre distinto al planificado." + "unknown_service": "Servicio desconocido. Puede tratarse de un refuerzo o de que el servicio lleva un nombre distinto al planificado.", + "delay_on_time": "En hora (0 min)", + "delay_positive": "Retraso de {{minutes}} min", + "delay_negative": "Adelanto de {{minutes}} min", + "previous_trip": "GPS estimado", + "bus_gps_position": "GPS fiable", + "low_accuracy": "Baja precisión", + "no_realtime": "Sin tiempo real" }, "timetable": { "fullCaption": "Horarios teóricos de la parada", @@ -103,5 +110,23 @@ }, "lines": { "description": "A continuación se muestra una lista de las líneas de autobús urbano de Vigo con sus respectivas rutas y enlaces a los horarios oficiales." + }, + "stop_help": { + "title": "Guía de estimaciones", + "realtime_ok": "Tiempo real fiable", + "realtime_ok_desc": "El autobús está teóricamente circulando, y la estimación se basa en datos en tiempo real fiables.", + "realtime_warning": "Estimación imprecisa", + "realtime_warning_desc": "Tenemos datos en tiempo real de un viaje que todavía no ha salido de cabecera. La estimación puede ser demasiado optimista, o pesimista próxima a la salida.", + "scheduled": "Horario programado", + "scheduled_desc": "No hay datos en tiempo real. Se muestra la hora teórica de paso según los datos teóricos. Suele ocurrir al inicio de algunas líneas combinadas o el primer viaje del día.", + "gps": "Posición GPS", + "gps_desc": "Indica que conocemos la ubicación aproximada del autobús, a partir de la distancia en metros que nos reporta la empresa y la ruta que creemos que está siguiendo.", + "punctuality": "Puntualidad", + "punctuality_ontime": "El autobús circula según el horario previsto (con un margen de cortesía).", + "punctuality_early": "El autobús va adelantado respecto al horario (2 minutos o más).", + "punctuality_late": "El autobús circula con retraso (4 minutos o más).", + "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." } } diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index db7b6ec..f41e38c 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -63,7 +63,14 @@ "trip": "viaxe", "last_updated": "Actualizado ás", "reload": "Recargar", - "unknown_service": "Servizo descoñecido. Pode tratarse dun reforzo ou de que o servizo leva un nome distinto ó planificado." + "unknown_service": "Servizo descoñecido. Pode tratarse dun reforzo ou de que o servizo leva un nome distinto ó planificado.", + "delay_on_time": "En hora (0 min)", + "delay_positive": "Atraso de {{minutes}} min", + "delay_negative": "Adelanto de {{minutes}} min", + "previous_trip": "GPS estimado", + "bus_gps_position": "GPS fiable", + "low_accuracy": "Baixa precisión", + "no_realtime": "Sen tempo real" }, "timetable": { "fullCaption": "Horarios teóricos da parada", @@ -103,5 +110,23 @@ }, "lines": { "description": "A continuación se mostra unha lista das liñas de autobús urbano de Vigo coas súas respectivas rutas e ligazóns ós horarios oficiais." + }, + "stop_help": { + "title": "Guía de estimacións", + "realtime_ok": "Tempo real fiable", + "realtime_ok_desc": "O autobús está teoricamente circulando, e a estimación baséase en datos en tempo real fiables.", + "realtime_warning": "Estimación imprecisa", + "realtime_warning_desc": "Temos datos en tempo real dunha viaxe que aínda non saíu de cabeceira. A estimación pode ser demasiado optimista, ou pesimista próxima á saída.", + "scheduled": "Horario programado", + "scheduled_desc": "Non hai datos en tempo real. Móstrase a hora teórica de paso segundo os datos teóricos. Adoita ocorrer ao inicio dalgunhas liñas combinadas ou a primeira viaxe do día.", + "gps": "Posición GPS", + "gps_desc": "Indica que coñecemos a localización aproximada do autobús, a partir da distancia en metros que nos reporta a empresa e a ruta que cremos que está a seguir.", + "punctuality": "Puntualidade", + "punctuality_ontime": "O autobús circula segundo o horario previsto (cunha marxe de cortesía).", + "punctuality_early": "O autobús vai adiantado respecto ao horario (2 minutos ou máis).", + "punctuality_late": "O autobús circula con atraso (4 minutos ou máis).", + "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." } } diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index d836c12..5260c32 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -1,4 +1,4 @@ -import { Eye, EyeClosed, RefreshCw, 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"; @@ -6,6 +6,7 @@ import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; import { PullToRefresh } from "~/components/PullToRefresh"; import { StopAlert } from "~/components/StopAlert"; +import { StopHelpModal } from "~/components/StopHelpModal"; import { StopMapModal } from "~/components/StopMapModal"; import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; @@ -113,6 +114,7 @@ export default function Estimates() { const [favourited, setFavourited] = useState(false); const [isManualRefreshing, setIsManualRefreshing] = useState(false); const [isMapModalOpen, setIsMapModalOpen] = useState(false); + const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); const [isReducedView, setIsReducedView] = useState(false); const [selectedCirculationId, setSelectedCirculationId] = useState< string | undefined @@ -242,16 +244,16 @@ export default function Estimates() { ) : data ? ( <>
-
+
- + setIsHelpModalOpen(true)} />
@@ -301,6 +303,11 @@ export default function Estimates() { selectedCirculationId={selectedCirculationId} /> )} + + setIsHelpModalOpen(false)} + />
); -- cgit v1.3