aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-08 00:33:16 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-08 00:33:27 +0100
commit417930355fbe6089536c60c3ffba75c8691ca581 (patch)
treee6f9a1abd1b29b9690944385a7d4011782abea01 /src
parent579f61a84c351e8c2e0f1e3962d1969541ca39fa (diff)
feat: add StopHelpModal component and integrate help functionality in Estimates
Diffstat (limited to 'src')
-rw-r--r--src/frontend/app/components/StopHelpModal.tsx153
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx60
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json27
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json27
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json27
-rw-r--r--src/frontend/app/routes/stops-$id.tsx21
6 files changed, 291 insertions, 24 deletions
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<StopHelpModalProps> = ({
+ isOpen,
+ onClose,
+}) => {
+ const { t } = useTranslation();
+
+ 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>
+ <div className="p-6 pb-10 flex flex-col gap-8 overflow-y-auto max-h-[80vh] text-slate-900 dark:text-slate-100">
+ <div>
+ <h2 className="text-xl font-bold mb-4">
+ {t("stop_help.title")}
+ </h2>
+
+ <div className="space-y-5">
+ <div className="flex gap-4 items-start">
+ <div className="w-10 h-10 rounded-full bg-green-600/20 flex items-center justify-center shrink-0 mt-0.5">
+ <Clock className="w-6 h-6 text-green-700 dark:text-green-400" />
+ </div>
+ <div>
+ <h3 className="font-semibold text-green-700 dark:text-green-400 text-base">
+ {t("stop_help.realtime_ok")}
+ </h3>
+ <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
+ {t("stop_help.realtime_ok_desc")}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex gap-4 items-start">
+ <div className="w-10 h-10 rounded-full bg-orange-600/20 flex items-center justify-center shrink-0 mt-0.5">
+ <AlertTriangle className="w-6 h-6 text-orange-700 dark:text-orange-400" />
+ </div>
+ <div>
+ <h3 className="font-semibold text-orange-700 dark:text-orange-400 text-base">
+ {t("stop_help.realtime_warning")}
+ </h3>
+ <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
+ {t("stop_help.realtime_warning_desc")}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex gap-4 items-start">
+ <div className="w-10 h-10 rounded-full bg-blue-900/20 flex items-center justify-center shrink-0 mt-0.5">
+ <Clock className="w-6 h-6 text-blue-900 dark:text-blue-400" />
+ </div>
+ <div>
+ <h3 className="font-semibold text-blue-900 dark:text-blue-400 text-base">
+ {t("stop_help.scheduled")}
+ </h3>
+ <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
+ {t("stop_help.scheduled_desc")}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex gap-4 items-start">
+ <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center shrink-0 mt-0.5">
+ <LocateIcon className="w-6 h-6 text-slate-700 dark:text-slate-300" />
+ </div>
+ <div>
+ <h3 className="font-semibold text-slate-900 dark:text-slate-100 text-base">
+ {t("stop_help.gps")}
+ </h3>
+ <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
+ {t("stop_help.gps_desc")}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <h2 className="text-lg font-bold mb-4">
+ {t("stop_help.punctuality")}
+ </h2>
+
+ <div className="space-y-3">
+ <div className="flex items-center gap-3">
+ <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300 shrink-0">
+ {t("stop_help.punctuality_ontime_label", "En hora")}
+ </span>
+ <p className="text-sm text-slate-600 dark:text-slate-400">
+ {t("stop_help.punctuality_ontime")}
+ </p>
+ </div>
+
+ <div className="flex items-center gap-3">
+ <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300 shrink-0">
+ {t("stop_help.punctuality_early_label", "Adelanto")}
+ </span>
+ <p className="text-sm text-slate-600 dark:text-slate-400">
+ {t("stop_help.punctuality_early")}
+ </p>
+ </div>
+
+ <div className="flex items-center gap-3">
+ <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300 shrink-0">
+ {t("stop_help.punctuality_late_label", "Retraso")}
+ </span>
+ <p className="text-sm text-slate-600 dark:text-slate-400">
+ {t("stop_help.punctuality_late")}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <h2 className="text-lg font-bold mb-4">
+ {t("stop_help.gps_quality")}
+ </h2>
+
+ <div className="space-y-3">
+ <div className="flex items-center gap-3">
+ <span className="px-2 py-0.5 rounded-full text-xs font-medium shrink-0">
+ {t("stop_help.gps_reliable_label", "GPS fiable")}
+ </span>
+ <p className="text-sm text-slate-600 dark:text-slate-400">
+ {t("stop_help.gps_reliable")}
+ </p>
+ </div>
+
+ <div className="flex items-center gap-3">
+ <span className="px-2 py-0.5 rounded-full text-xs font-medium shrink-0">
+ {t("stop_help.gps_imprecise_label", "GPS impreciso")}
+ </span>
+ <p className="text-sm text-slate-600 dark:text-slate-400">
+ {t("stop_help.gps_imprecise")}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop onTap={onClose} />
+ </Sheet>
+ );
+};
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" && (<LocateIcon className="w-3 h-3 inline-block" />)}
+ {chip.kind === "warning" && (<AlertTriangle className="w-3 h-3 inline-block" />)}
{chip.label}
</span>
);
@@ -284,14 +322,6 @@ export const ConsolidatedCirculationCard: React.FC<
);
})()}
</div>
- {hasGpsPosition && (
- <div className="gps-indicator" title="Live GPS tracking">
- <span
- className={`gps-pulse ${estimate.isPreviousTrip ? "previous-trip" : ""
- }`}
- />
- </div>
- )}
<div className={`eta-badge ${timeClass}`}>
<div className="eta-text">
<span className="eta-value">{etaValue}</span>
@@ -307,6 +337,8 @@ export const ConsolidatedCirculationCard: React.FC<
key={`${chip.label}-${idx}`}
className={`meta-chip ${chip.tone ?? ""}`.trim()}
>
+ {chip.kind === "gps" && (<LocateIcon className="w-3 h-3 inline-block" />)}
+ {chip.kind === "warning" && (<AlertTriangle className="w-3 h-3 inline-block" />)}
{chip.label}
</span>
))}
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 ? (
<>
<div className="flex items-center justify-between py-2">
- <div className="flex items-center gap-4">
+ <div className="flex items-center gap-8">
<Star
- className={`text-slate-500 ${favourited ? "active" : ""}`}
+ className={`cursor-pointer transition-colors ${favourited
+ ? "fill-[var(--star-color)] text-[var(--star-color)]"
+ : "text-slate-500"
+ }`}
onClick={toggleFavourite}
/>
- <RefreshCw
- className={`text-slate-500 ${isManualRefreshing ? "spinning" : ""}`}
- onClick={handleManualRefresh}
- />
+ <CircleHelp className="text-slate-500 cursor-pointer" onClick={() => setIsHelpModalOpen(true)} />
</div>
<div className="consolidated-circulation-caption">
@@ -301,6 +303,11 @@ export default function Estimates() {
selectedCirculationId={selectedCirculationId}
/>
)}
+
+ <StopHelpModal
+ isOpen={isHelpModalOpen}
+ onClose={() => setIsHelpModalOpen(false)}
+ />
</div>
</PullToRefresh>
);