aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes/stops-$id.tsx
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-07 14:58:32 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-07 14:58:32 +0100
commit52256fd634300b39b915bf1db6020d9d2871a0b4 (patch)
treed32b219427e5974026c544132e3b0a65ffa0a82e /src/frontend/app/routes/stops-$id.tsx
parent4420def7411a053e930b44117e2bf63625d824dc (diff)
Implement experimental consolidated circulation view
Diffstat (limited to 'src/frontend/app/routes/stops-$id.tsx')
-rw-r--r--src/frontend/app/routes/stops-$id.tsx262
1 files changed, 262 insertions, 0 deletions
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
new file mode 100644
index 0000000..ea60da7
--- /dev/null
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -0,0 +1,262 @@
+import { useEffect, useState, useCallback } from "react";
+import { useParams, Link } from "react-router";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import { Star, Edit2, ExternalLink, RefreshCw } from "lucide-react";
+import "./estimates-$id.css";
+import { useApp } from "../AppContext";
+import { useTranslation } from "react-i18next";
+import { PullToRefresh } from "~/components/PullToRefresh";
+import { useAutoRefresh } from "~/hooks/useAutoRefresh";
+import { type RegionId, getRegionConfig } from "~/data/RegionConfig";
+import { StopAlert } from "~/components/StopAlert";
+import LineIcon from "~/components/LineIcon";
+import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
+
+export interface ConsolidatedCirculation {
+ line: string;
+ route: string;
+ schedule?: {
+ running: boolean;
+ minutes: number;
+ serviceId: string;
+ tripId: string;
+ };
+ realTime?: {
+ minutes: number;
+ distance: number;
+ };
+}
+
+
+interface ErrorInfo {
+ type: "network" | "server" | "unknown";
+ status?: number;
+ message?: string;
+}
+
+const loadConsolidatedData = async (
+ region: RegionId,
+ stopId: string,
+): Promise<ConsolidatedCirculation[]> => {
+ const regionConfig = getRegionConfig(region);
+ const resp = await fetch(`${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ return await resp.json();
+};
+
+export default function Estimates() {
+ const { t } = useTranslation();
+ const params = useParams();
+ const stopIdNum = parseInt(params.id ?? "");
+ const [customName, setCustomName] = useState<string | undefined>(undefined);
+ const [stopData, setStopData] = useState<Stop | undefined>(undefined);
+
+ // Data state
+ const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
+ const [dataDate, setDataDate] = useState<Date | null>(null);
+ const [dataLoading, setDataLoading] = useState(true);
+ const [dataError, setDataError] = useState<ErrorInfo | null>(null);
+
+ const [favourited, setFavourited] = useState(false);
+ const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+ const { region } = useApp();
+ const regionConfig = getRegionConfig(region);
+
+ const parseError = (error: any): ErrorInfo => {
+ if (!navigator.onLine) {
+ return { type: "network", message: "No internet connection" };
+ }
+
+ if (
+ error.message?.includes("Failed to fetch") ||
+ error.message?.includes("NetworkError")
+ ) {
+ return { type: "network" };
+ }
+
+ if (error.message?.includes("HTTP")) {
+ const statusMatch = error.message.match(/HTTP (\d+):/);
+ const status = statusMatch ? parseInt(statusMatch[1]) : undefined;
+ return { type: "server", status };
+ }
+
+ return { type: "unknown", message: error.message };
+ };
+
+ const loadData = useCallback(async () => {
+ try {
+ setDataLoading(true);
+ setDataError(null);
+
+ const body = await loadConsolidatedData(region, params.id!);
+ setData(body);
+ setDataDate(new Date());
+
+ // Load stop data from StopDataProvider
+ const stop = await StopDataProvider.getStopById(region, stopIdNum);
+ setStopData(stop);
+ setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
+ } catch (error) {
+ console.error("Error loading consolidated data:", error);
+ setDataError(parseError(error));
+ setData(null);
+ setDataDate(null);
+ } finally {
+ setDataLoading(false);
+ }
+ }, [params.id, stopIdNum, region]);
+
+ const refreshData = useCallback(async () => {
+ await Promise.all([loadData()]);
+ }, [loadData]);
+
+ // Manual refresh function for pull-to-refresh and button
+ const handleManualRefresh = useCallback(async () => {
+ try {
+ setIsManualRefreshing(true);
+ // Only reload real-time estimates data, not timetable
+ await refreshData();
+ } finally {
+ setIsManualRefreshing(false);
+ }
+ }, [refreshData]);
+
+ // Auto-refresh estimates data every 30 seconds (only if not in error state)
+ useAutoRefresh({
+ onRefresh: refreshData,
+ interval: 30000,
+ enabled: !dataError,
+ });
+
+ useEffect(() => {
+ // Initial load
+ loadData();
+
+ StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
+ setFavourited(
+ StopDataProvider.isFavourite(region, parseInt(params.id ?? "")),
+ );
+ }, [params.id, region, loadData]);
+
+ const toggleFavourite = () => {
+ if (favourited) {
+ StopDataProvider.removeFavourite(region, stopIdNum);
+ setFavourited(false);
+ } else {
+ StopDataProvider.addFavourite(region, stopIdNum);
+ setFavourited(true);
+ }
+ };
+
+ // Helper function to get the display name for the stop
+ const getStopDisplayName = () => {
+ if (customName) return customName;
+ if (stopData?.name.intersect) return stopData.name.intersect;
+ if (stopData?.name.original) return stopData.name.original;
+ return `Parada ${stopIdNum}`;
+ };
+
+ const handleRename = () => {
+ const current = getStopDisplayName();
+ const input = window.prompt("Custom name for this stop:", current);
+ if (input === null) return; // cancelled
+ const trimmed = input.trim();
+ if (trimmed === "") {
+ StopDataProvider.removeCustomName(region, stopIdNum);
+ setCustomName(undefined);
+ } else {
+ StopDataProvider.setCustomName(region, stopIdNum, trimmed);
+ setCustomName(trimmed);
+ }
+ };
+
+ // Show loading skeleton while initial data is loading
+ if (dataLoading && !data) {
+ return (
+ <PullToRefresh
+ onRefresh={handleManualRefresh}
+ isRefreshing={isManualRefreshing}
+ >
+ <div className="page-container estimates-page">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star className="star-icon" />
+ <Edit2 className="edit-icon" />
+ {t("common.loading")}...
+ </h1>
+ </div>
+
+ <div className="table-responsive">
+ {/* TODO: Implement skeleton */}
+ </div>
+ </div>
+ </PullToRefresh>
+ );
+ }
+
+ return (
+ <PullToRefresh
+ onRefresh={handleManualRefresh}
+ isRefreshing={isManualRefreshing}
+ >
+ <div className="page-container estimates-page">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star
+ className={`star-icon ${favourited ? "active" : ""}`}
+ onClick={toggleFavourite}
+ />
+ <Edit2 className="edit-icon" onClick={handleRename} />
+ {getStopDisplayName()}{" "}
+ <span className="estimates-stop-id">({stopIdNum})</span>
+ </h1>
+
+ <button
+ className="manual-refresh-button"
+ onClick={handleManualRefresh}
+ disabled={isManualRefreshing || dataLoading}
+ title={t("estimates.reload", "Recargar estimaciones")}
+ >
+ <RefreshCw
+ className={`refresh-icon ${isManualRefreshing ? "spinning" : ""}`}
+ />
+ </button>
+ </div>
+
+ {stopData && stopData.lines && stopData.lines.length > 0 && (
+ <div
+ className={`estimates-lines-container`}
+ >
+ {stopData.lines.map((line) => (
+ <div key={line} className="estimates-line-icon">
+ <LineIcon line={line} region={region} rounded />
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="experimental-notice">
+ <strong>{t("estimates.experimental_feature", "Experimental feature")}</strong>
+ <p>{t("estimates.experimental_description", "This view uses consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate.")}</p>
+ </div>
+
+ {stopData && <StopAlert stop={stopData} />}
+
+ <div className="table-responsive">
+ {data ? (<>
+ <ConsolidatedCirculationList data={data} dataDate={dataDate} regionConfig={regionConfig} />
+ </>) : null}
+ </div>
+
+ </div>
+ </PullToRefresh>
+ );
+}