diff options
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 33 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 13 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.css | 3 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 36 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 262 |
5 files changed, 324 insertions, 23 deletions
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index bb68c37..72ade06 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -237,3 +237,36 @@ .estimates-line-icon { flex-shrink: 0; } + +.experimental-notice { + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + color: #856404; +} + +.experimental-notice strong { + display: block; + margin-bottom: 0.5rem; + color: #856404; +} + +.experimental-notice p { + margin: 0; + font-size: 0.9rem; + line-height: 1.4; +} + +@media (prefers-color-scheme: dark) { + .experimental-notice { + background-color: #3d3100; + border-color: #ffc107; + color: #ffd966; + } + + .experimental-notice strong { + color: #ffd966; + } +} diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index 4502d9e..81c83ea 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -1,5 +1,5 @@ import { type JSX, useEffect, useState, useCallback } from "react"; -import { useParams, Link } from "react-router"; +import { useParams, Link, Navigate } from "react-router"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import { Star, Edit2, ExternalLink, RefreshCw } from "lucide-react"; import "./estimates-$id.css"; @@ -105,6 +105,11 @@ export default function Estimates() { const { tableStyle, region } = useApp(); const regionConfig = getRegionConfig(region); + // Redirect to /stops/$id if table style is experimental_consolidated + if (tableStyle === "experimental_consolidated") { + return <Navigate to={`/stops/${params.id}`} replace />; + } + const parseError = (error: any): ErrorInfo => { if (!navigator.onLine) { return { type: "network", message: "No internet connection" }; @@ -171,10 +176,6 @@ export default function Estimates() { } }, [params.id, region, regionConfig.timetableEndpoint]); - const refreshData = useCallback(async () => { - await Promise.all([loadEstimatesData(), loadTimetableDataAsync()]); - }, [loadEstimatesData, loadTimetableDataAsync]); - // Manual refresh function for pull-to-refresh and button const handleManualRefresh = useCallback(async () => { try { @@ -299,7 +300,7 @@ export default function Estimates() { {stopData && stopData.lines && stopData.lines.length > 0 && ( <div - className={`estimates-lines-container ${stopData.lines.length >= 6 ? "scrollable" : ""}`} + className={`estimates-lines-container`} > {stopData.lines.map((line) => ( <div key={line} className="estimates-line-icon"> diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css index d82cf8b..02708a7 100644 --- a/src/frontend/app/routes/settings.css +++ b/src/frontend/app/routes/settings.css @@ -37,7 +37,8 @@ .settings-content-inline { display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; margin-bottom: 1em; } diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index d687fab..c916877 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -83,22 +83,6 @@ export default function Settings() { </select> </div> <div className="settings-content-inline"> - <label htmlFor="tableStyle" className="form-label-inline"> - {t("about.table_style")} - </label> - <select - id="tableStyle" - className="form-select-inline" - value={tableStyle} - onChange={(e) => - setTableStyle(e.target.value as "regular" | "grouped") - } - > - <option value="regular">{t("about.table_style_regular")}</option> - <option value="grouped">{t("about.table_style_grouped")}</option> - </select> - </div> - <div className="settings-content-inline"> <label htmlFor="mapPositionMode" className="form-label-inline"> {t("about.map_position_mode")} </label> @@ -129,6 +113,24 @@ export default function Settings() { <option value="en-GB">English</option> </select> </div> + + <div className="settings-content-inline"> + <label htmlFor="tableStyle" className="form-label-inline"> + {t("about.table_style")} + </label> + <select + id="tableStyle" + className="form-select-inline" + value={tableStyle} + onChange={(e) => + setTableStyle(e.target.value as "regular" | "grouped" | "experimental_consolidated") + } + > + <option value="regular">{t("about.table_style_regular")}</option> + <option value="grouped">{t("about.table_style_grouped")}</option> + <option value="experimental_consolidated">{t("about.table_style_experimental_consolidated")}</option> + </select> + </div> <details className="form-details"> <summary>{t("about.details_summary")}</summary> <p>{t("about.details_table")}</p> @@ -137,6 +139,8 @@ export default function Settings() { <dd>{t("about.details_regular")}</dd> <dt>{t("about.table_style_grouped")}</dt> <dd>{t("about.details_grouped")}</dd> + <dt>{t("about.table_style_experimental_consolidated")}</dt> + <dd>{t("about.details_experimental_consolidated")}</dd> </dl> </details> </section> 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> + ); +} |
