aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/AppContext.tsx29
-rw-r--r--src/frontend/app/components/GroupedTable.tsx24
-rw-r--r--src/frontend/app/components/LineIcon.css295
-rw-r--r--src/frontend/app/components/LineIcon.tsx17
-rw-r--r--src/frontend/app/components/RegionSelector.tsx33
-rw-r--r--src/frontend/app/components/RegularTable.tsx23
-rw-r--r--src/frontend/app/components/StopItem.tsx7
-rw-r--r--src/frontend/app/components/StopSheet.tsx17
-rw-r--r--src/frontend/app/components/TimetableTable.tsx4
-rw-r--r--src/frontend/app/data/RegionConfig.ts49
-rw-r--r--src/frontend/app/data/StopDataProvider.ts113
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx54
-rw-r--r--src/frontend/app/routes/map.tsx8
-rw-r--r--src/frontend/app/routes/settings.tsx22
-rw-r--r--src/frontend/app/routes/stoplist.tsx16
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx35
16 files changed, 406 insertions, 340 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx
index 9013463..1a9b511 100644
--- a/src/frontend/app/AppContext.tsx
+++ b/src/frontend/app/AppContext.tsx
@@ -7,6 +7,7 @@ import {
type ReactNode,
} from "react";
import { type LngLatLike } from "maplibre-gl";
+import { type RegionId, DEFAULT_REGION, getRegionConfig, isValidRegion } from "./data/RegionConfig";
export type Theme = "light" | "dark" | "system";
type TableStyle = "regular" | "grouped";
@@ -37,6 +38,9 @@ interface AppContextProps {
mapPositionMode: MapPositionMode;
setMapPositionMode: (mode: MapPositionMode) => void;
+
+ region: RegionId;
+ setRegion: (region: RegionId) => void;
}
// Coordenadas por defecto centradas en Vigo
@@ -153,6 +157,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
}, [mapPositionMode]);
//#endregion
+ //#region Region
+ const [region, setRegionState] = useState<RegionId>(() => {
+ const savedRegion = localStorage.getItem("region");
+ if (savedRegion && isValidRegion(savedRegion)) {
+ return savedRegion;
+ }
+ return DEFAULT_REGION;
+ });
+
+ const setRegion = (newRegion: RegionId) => {
+ setRegionState(newRegion);
+ localStorage.setItem("region", newRegion);
+
+ // Update map to region's default center and zoom
+ const regionConfig = getRegionConfig(newRegion);
+ updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom);
+ };
+
+ useEffect(() => {
+ localStorage.setItem("region", region);
+ }, [region]);
+ //#endregion
+
//#region Map State
const [mapState, setMapState] = useState<MapState>(() => {
const savedMapState = localStorage.getItem("mapState");
@@ -253,6 +280,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
updateMapState,
mapPositionMode,
setMapPositionMode,
+ region,
+ setRegion,
}}
>
{children}
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
index 47c2d31..fd97d5b 100644
--- a/src/frontend/app/components/GroupedTable.tsx
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -1,12 +1,14 @@
import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
+import { type RegionConfig } from "../data/RegionConfig";
interface GroupedTable {
data: StopDetails;
dataDate: Date | null;
+ regionConfig: RegionConfig;
}
-export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
+export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate, regionConfig }) => {
const formatDistance = (meters: number) => {
if (meters > 1024) {
return `${(meters / 1000).toFixed(1)} km`;
@@ -43,7 +45,7 @@ export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
<th>Línea</th>
<th>Ruta</th>
<th>Llegada</th>
- <th>Distancia</th>
+ {regionConfig.showMeters && <th>Distancia</th>}
</tr>
</thead>
@@ -53,16 +55,18 @@ export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
<tr key={`${line}-${idx}`}>
{idx === 0 && (
<td rowSpan={groupedEstimates[line].length}>
- <LineIcon line={line} />
+ <LineIcon line={line} region={regionConfig.id} />
</td>
)}
<td>{estimate.route}</td>
<td>{`${estimate.minutes} min`}</td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : "No disponible"}
- </td>
+ {regionConfig.showMeters && (
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : "No disponible"}
+ </td>
+ )}
</tr>
)),
)}
@@ -71,7 +75,9 @@ export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
{data?.estimates.length === 0 && (
<tfoot>
<tr>
- <td colSpan={4}>No hay estimaciones disponibles</td>
+ <td colSpan={regionConfig.showMeters ? 4 : 3}>
+ No hay estimaciones disponibles
+ </td>
</tr>
</tfoot>
)}
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index 4613a85..7d46b98 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -1,49 +1,75 @@
+/* Vigo line colors */
:root {
- --line-c1: rgb(237, 71, 19);
- --line-c3d: rgb(255, 204, 0);
- --line-c3i: rgb(255, 204, 0);
- --line-l4a: rgb(0, 153, 0);
- --line-l4c: rgb(0, 153, 0);
- --line-l5a: rgb(0, 176, 240);
- --line-l5b: rgb(0, 176, 240);
- --line-l6: rgb(204, 51, 153);
- --line-l7: rgb(150, 220, 153);
- --line-l9b: rgb(244, 202, 140);
- --line-l10: rgb(153, 51, 0);
- --line-l11: rgb(226, 0, 38);
- --line-l12a: rgb(106, 150, 190);
- --line-l12b: rgb(106, 150, 190);
- --line-l13: rgb(0, 176, 240);
- --line-l14: rgb(129, 142, 126);
- --line-l15a: rgb(216, 168, 206);
- --line-l15b: rgb(216, 168, 206);
- --line-l15c: rgb(216, 168, 168);
- --line-l16: rgb(129, 142, 126);
- --line-l17: rgb(214, 245, 31);
- --line-l18a: rgb(212, 80, 168);
- --line-l18b: rgb(0, 0, 0);
- --line-l18h: rgb(0, 0, 0);
- --line-l23: rgb(0, 70, 210);
- --line-l24: rgb(191, 191, 191);
- --line-l25: rgb(172, 100, 4);
- --line-l27: rgb(112, 74, 42);
- --line-l28: rgb(176, 189, 254);
- --line-l29: rgb(248, 184, 90);
- --line-l31: rgb(255, 255, 0);
- --line-a: rgb(119, 41, 143);
- --line-h: rgb(0, 96, 168);
- --line-h1: rgb(0, 96, 168);
- --line-h2: rgb(0, 96, 168);
- --line-h3: rgb(0, 96, 168);
- --line-lzd: rgb(61, 78, 167);
- --line-n1: rgb(191, 191, 191);
- --line-n4: rgb(102, 51, 102);
- --line-psa1: rgb(0, 153, 0);
- --line-psa4: rgb(0, 153, 0);
- --line-ptl: rgb(150, 220, 153);
- --line-turistico: rgb(102, 51, 102);
- --line-u1: rgb(172, 100, 4);
- --line-u2: rgb(172, 100, 4);
+ --line-vigo-c1: rgb(237, 71, 19);
+ --line-vigo-c3d: rgb(255, 204, 0);
+ --line-vigo-c3i: rgb(255, 204, 0);
+ --line-vigo-l4a: rgb(0, 153, 0);
+ --line-vigo-l4c: rgb(0, 153, 0);
+ --line-vigo-l5a: rgb(0, 176, 240);
+ --line-vigo-l5b: rgb(0, 176, 240);
+ --line-vigo-l6: rgb(204, 51, 153);
+ --line-vigo-l7: rgb(150, 220, 153);
+ --line-vigo-l9b: rgb(244, 202, 140);
+ --line-vigo-l10: rgb(153, 51, 0);
+ --line-vigo-l11: rgb(226, 0, 38);
+ --line-vigo-l12a: rgb(106, 150, 190);
+ --line-vigo-l12b: rgb(106, 150, 190);
+ --line-vigo-l13: rgb(0, 176, 240);
+ --line-vigo-l14: rgb(129, 142, 126);
+ --line-vigo-l15a: rgb(216, 168, 206);
+ --line-vigo-l15b: rgb(216, 168, 206);
+ --line-vigo-l15c: rgb(216, 168, 168);
+ --line-vigo-l16: rgb(129, 142, 126);
+ --line-vigo-l17: rgb(214, 245, 31);
+ --line-vigo-l18a: rgb(212, 80, 168);
+ --line-vigo-l18b: rgb(0, 0, 0);
+ --line-vigo-l18h: rgb(0, 0, 0);
+ --line-vigo-l23: rgb(0, 70, 210);
+ --line-vigo-l24: rgb(191, 191, 191);
+ --line-vigo-l25: rgb(172, 100, 4);
+ --line-vigo-l27: rgb(112, 74, 42);
+ --line-vigo-l28: rgb(176, 189, 254);
+ --line-vigo-l29: rgb(248, 184, 90);
+ --line-vigo-l31: rgb(255, 255, 0);
+ --line-vigo-a: rgb(119, 41, 143);
+ --line-vigo-h: rgb(0, 96, 168);
+ --line-vigo-h1: rgb(0, 96, 168);
+ --line-vigo-h2: rgb(0, 96, 168);
+ --line-vigo-h3: rgb(0, 96, 168);
+ --line-vigo-lzd: rgb(61, 78, 167);
+ --line-vigo-n1: rgb(191, 191, 191);
+ --line-vigo-n4: rgb(102, 51, 102);
+ --line-vigo-psa1: rgb(0, 153, 0);
+ --line-vigo-psa4: rgb(0, 153, 0);
+ --line-vigo-ptl: rgb(150, 220, 153);
+ --line-vigo-turistico: rgb(102, 51, 102);
+ --line-vigo-u1: rgb(172, 100, 4);
+ --line-vigo-u2: rgb(172, 100, 4);
+
+ --line-santiago-l1: #f32621;
+ --line-santiago-l4: #ffcc33;
+ --line-santiago-l5: #fa8405;
+ --line-santiago-l6: #d73983;
+ --line-santiago-l6a: #d73983;
+ --line-santiago-l7: #488bc1;
+ --line-santiago-l8: #6aaf48;
+ --line-santiago-l9: #46b8bb;
+ --line-santiago-c11: #aec741;
+ --line-santiago-l12: #842e14;
+ --line-santiago-l13: #336600;
+ --line-santiago-l15: #7a4b2a;
+ --line-santiago-c2: #283a87;
+ --line-santiago-c4: #283a87;
+ --line-santiago-c5: #999999;
+ --line-santiago-c6: #006666;
+ --line-santiago-p1: #537eb3;
+ --line-santiago-p2: #d23354;
+ --line-santiago-p3: #75bd96;
+ --line-santiago-p4: #f1c54f;
+ --line-santiago-p6: #999999;
+ --line-santiago-p7: #d2438c;
+ --line-santiago-p8: #e28c3a;
+
}
.line-icon {
@@ -55,187 +81,8 @@
font-weight: 600;
text-transform: uppercase;
border-radius: 0.25rem 0.25rem 0 0;
-
color: var(--text-color);
background-color: var(--background-color);
}
-.line-c1 {
- border-color: var(--line-c1);
-}
-
-.line-c3d {
- border-color: var(--line-c3d);
-}
-
-.line-c3i {
- border-color: var(--line-c3i);
-}
-
-.line-l4a {
- border-color: var(--line-l4a);
-}
-
-.line-l4c {
- border-color: var(--line-l4c);
-}
-
-.line-l5a {
- border-color: var(--line-l5a);
-}
-
-.line-l5b {
- border-color: var(--line-l5b);
-}
-
-.line-l6 {
- border-color: var(--line-l6);
-}
-
-.line-l7 {
- border-color: var(--line-l7);
-}
-
-.line-l9b {
- border-color: var(--line-l9b);
-}
-
-.line-l10 {
- border-color: var(--line-l10);
-}
-
-.line-l11 {
- border-color: var(--line-l11);
-}
-
-.line-l12a {
- border-color: var(--line-l12a);
-}
-
-.line-l12b {
- border-color: var(--line-l12b);
-}
-
-.line-l13 {
- border-color: var(--line-l13);
-}
-
-.line-l14 {
- border-color: var(--line-l14);
-}
-
-.line-l15a {
- border-color: var(--line-l15a);
-}
-
-.line-l15b {
- border-color: var(--line-l15b);
-}
-
-.line-l15c {
- border-color: var(--line-l15c);
-}
-
-.line-l16 {
- border-color: var(--line-l16);
-}
-
-.line-l17 {
- border-color: var(--line-l17);
-}
-
-.line-l18a {
- border-color: var(--line-l18a);
-}
-.line-l18b {
- border-color: var(--line-l18b);
-}
-
-.line-l18h {
- border-color: var(--line-l18h);
-}
-
-.line-l23 {
- border-color: var(--line-l23);
-}
-
-.line-l24 {
- border-color: var(--line-l24);
-}
-
-.line-l25 {
- border-color: var(--line-l25);
-}
-
-.line-l27 {
- border-color: var(--line-l27);
-}
-
-.line-l28 {
- border-color: var(--line-l28);
-}
-
-.line-l29 {
- border-color: var(--line-l29);
-}
-
-.line-l31 {
- border-color: var(--line-l31);
-}
-
-.line-a {
- border-color: var(--line-a);
-}
-
-.line-h {
- border-color: var(--line-h);
-}
-
-.line-h1 {
- border-color: var(--line-h1);
-}
-
-.line-h2 {
- border-color: var(--line-h2);
-}
-
-.line-h3 {
- border-color: var(--line-h3);
-}
-
-.line-lzd {
- border-color: var(--line-lzd);
-}
-
-.line-n1 {
- border-color: var(--line-n1);
-}
-
-.line-n4 {
- border-color: var(--line-n4);
-}
-
-.line-psa1 {
- border-color: var(--line-psa1);
-}
-
-.line-psa4 {
- border-color: var(--line-psa4);
-}
-
-.line-ptl {
- border-color: var(--line-ptl);
-}
-
-.line-turistico {
- border-color: var(--line-turistico);
-}
-
-.line-u1 {
- border-color: var(--line-u1);
-}
-
-.line-u2 {
- border-color: var(--line-u2);
-}
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
index 3d613e6..4f4bfd9 100644
--- a/src/frontend/app/components/LineIcon.tsx
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -1,14 +1,23 @@
-import React from "react";
+import React, { useMemo } from "react";
import "./LineIcon.css";
+import { type RegionId } from "../data/RegionConfig";
interface LineIconProps {
line: string;
+ region?: RegionId;
}
-const LineIcon: React.FC<LineIconProps> = ({ line }) => {
- const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
+const LineIcon: React.FC<LineIconProps> = ({ line, region = "vigo" }) => {
+ const formattedLine = useMemo(() => {
+ return /^[a-zA-Z]/.test(line) ? line : `L${line}`;
+ }, [line]);
+ const cssVarName = `--line-${region}-${formattedLine.toLowerCase()}`;
+
return (
- <span className={`line-icon line-${formattedLine.toLowerCase()}`}>
+ <span
+ className="line-icon"
+ style={{ borderColor: `var(${cssVarName})` }}
+ >
{formattedLine}
</span>
);
diff --git a/src/frontend/app/components/RegionSelector.tsx b/src/frontend/app/components/RegionSelector.tsx
new file mode 100644
index 0000000..6c9fe8b
--- /dev/null
+++ b/src/frontend/app/components/RegionSelector.tsx
@@ -0,0 +1,33 @@
+import { useApp } from "../AppContext";
+import { getAvailableRegions } from "../data/RegionConfig";
+import "./RegionSelector.css";
+
+export function RegionSelector() {
+ const { region, setRegion } = useApp();
+ const regions = getAvailableRegions();
+
+ const handleRegionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+ const newRegion = e.target.value as any;
+ setRegion(newRegion);
+ };
+
+ return (
+ <div className="region-selector">
+ <label htmlFor="region-select" className="region-label">
+ Región:
+ </label>
+ <select
+ id="region-select"
+ className="region-select"
+ value={region}
+ onChange={handleRegionChange}
+ >
+ {regions.map((r) => (
+ <option key={r.id} value={r.id}>
+ {r.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ );
+}
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
index 8b01410..68b732a 100644
--- a/src/frontend/app/components/RegularTable.tsx
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -1,15 +1,18 @@
import { useTranslation } from "react-i18next";
import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
+import { type RegionConfig } from "../data/RegionConfig";
interface RegularTableProps {
data: StopDetails;
dataDate: Date | null;
+ regionConfig: RegionConfig;
}
export const RegularTable: React.FC<RegularTableProps> = ({
data,
dataDate,
+ regionConfig,
}) => {
const { t } = useTranslation();
@@ -46,7 +49,9 @@ export const RegularTable: React.FC<RegularTableProps> = ({
<th>{t("estimates.line", "Línea")}</th>
<th>{t("estimates.route", "Ruta")}</th>
<th>{t("estimates.arrival", "Llegada")}</th>
- <th>{t("estimates.distance", "Distancia")}</th>
+ {regionConfig.showMeters && (
+ <th>{t("estimates.distance", "Distancia")}</th>
+ )}
</tr>
</thead>
@@ -56,7 +61,7 @@ export const RegularTable: React.FC<RegularTableProps> = ({
.map((estimate, idx) => (
<tr key={idx}>
<td>
- <LineIcon line={estimate.line} />
+ <LineIcon line={estimate.line} region={regionConfig.id} />
</td>
<td>{estimate.route}</td>
<td>
@@ -64,11 +69,13 @@ export const RegularTable: React.FC<RegularTableProps> = ({
? absoluteArrivalTime(estimate.minutes)
: `${estimate.minutes} ${t("estimates.minutes", "min")}`}
</td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : t("estimates.not_available", "No disponible")}
- </td>
+ {regionConfig.showMeters && (
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : t("estimates.not_available", "No disponible")}
+ </td>
+ )}
</tr>
))}
</tbody>
@@ -76,7 +83,7 @@ export const RegularTable: React.FC<RegularTableProps> = ({
{data?.estimates.length === 0 && (
<tfoot>
<tr>
- <td colSpan={4}>
+ <td colSpan={regionConfig.showMeters ? 4 : 3}>
{t("estimates.none", "No hay estimaciones disponibles")}
</td>
</tr>
diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx
index b781eb9..7d89d7d 100644
--- a/src/frontend/app/components/StopItem.tsx
+++ b/src/frontend/app/components/StopItem.tsx
@@ -2,19 +2,22 @@ import React from "react";
import { Link } from "react-router";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import LineIcon from "./LineIcon";
+import { useApp } from "../AppContext";
interface StopItemProps {
stop: Stop;
}
const StopItem: React.FC<StopItemProps> = ({ stop }) => {
+ const { region } = useApp();
+
return (
<li className="list-item">
<Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
{stop.favourite && <span className="favourite-icon">★</span>} (
- {stop.stopId}) {StopDataProvider.getDisplayName(stop)}
+ {stop.stopId}) {StopDataProvider.getDisplayName(region, stop)}
<div className="line-icons">
- {stop.lines?.map((line) => <LineIcon key={line} line={line} />)}
+ {stop.lines?.map((line) => <LineIcon key={line} line={line} region={region} />)}
</div>
</Link>
</li>
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
index 702c574..7255884 100644
--- a/src/frontend/app/components/StopSheet.tsx
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -7,6 +7,8 @@ import LineIcon from "./LineIcon";
import { StopSheetSkeleton } from "./StopSheetSkeleton";
import { ErrorDisplay } from "./ErrorDisplay";
import { type StopDetails } from "../routes/estimates-$id";
+import { type RegionId, getRegionConfig } from "../data/RegionConfig";
+import { useApp } from "../AppContext";
import "./StopSheet.css";
interface StopSheetProps {
@@ -22,8 +24,9 @@ interface ErrorInfo {
message?: string;
}
-const loadStopData = async (stopId: number): Promise<StopDetails> => {
- const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
+const loadStopData = async (region: RegionId, stopId: number): Promise<StopDetails> => {
+ const regionConfig = getRegionConfig(region);
+ const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, {
headers: {
Accept: "application/json",
},
@@ -43,6 +46,8 @@ export const StopSheet: React.FC<StopSheetProps> = ({
stopName,
}) => {
const { t } = useTranslation();
+ const { region } = useApp();
+ const regionConfig = getRegionConfig(region);
const [data, setData] = useState<StopDetails | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo | null>(null);
@@ -72,7 +77,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
setError(null);
setData(null);
- const stopData = await loadStopData(stopId);
+ const stopData = await loadStopData(region, stopId);
setData(stopData);
setLastUpdated(new Date());
} catch (err) {
@@ -87,7 +92,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
if (isOpen && stopId) {
loadData();
}
- }, [isOpen, stopId]);
+ }, [isOpen, stopId, region]);
const formatTime = (minutes: number) => {
if (minutes > 15) {
@@ -157,7 +162,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
{limitedEstimates.map((estimate, idx) => (
<div key={idx} className="stop-sheet-estimate-item">
<div className="stop-sheet-estimate-line">
- <LineIcon line={estimate.line} />
+ <LineIcon line={estimate.line} region={region} />
</div>
<div className="stop-sheet-estimate-details">
<div className="stop-sheet-estimate-route">
@@ -165,7 +170,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
</div>
<div className="stop-sheet-estimate-time">
{formatTime(estimate.minutes)}
- {estimate.meters > -1 && (
+ {regionConfig.showMeters && estimate.meters > -1 && (
<span className="stop-sheet-estimate-distance">
{" • "}
{formatDistance(estimate.meters)}
diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx
index 86896ca..8215141 100644
--- a/src/frontend/app/components/TimetableTable.tsx
+++ b/src/frontend/app/components/TimetableTable.tsx
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import LineIcon from "./LineIcon";
import "./TimetableTable.css";
+import { useApp } from "../AppContext";
export interface TimetableEntry {
line: {
@@ -97,6 +98,7 @@ export const TimetableTable: React.FC<TimetableTableProps> = ({
currentTime
}) => {
const { t } = useTranslation();
+ const { region } = useApp();
const displayData = showAll ? data : findNearbyEntries(data, currentTime || '');
const nowMinutes = currentTime ? timeToMinutes(currentTime) : timeToMinutes(new Date().toTimeString().slice(0, 8));
@@ -126,7 +128,7 @@ export const TimetableTable: React.FC<TimetableTableProps> = ({
>
<div className="card-header">
<div className="line-info">
- <LineIcon line={entry.line.name} />
+ <LineIcon line={entry.line.name} region={region} />
</div>
<div className="destination-info">
diff --git a/src/frontend/app/data/RegionConfig.ts b/src/frontend/app/data/RegionConfig.ts
new file mode 100644
index 0000000..0ce66e6
--- /dev/null
+++ b/src/frontend/app/data/RegionConfig.ts
@@ -0,0 +1,49 @@
+export type RegionId = "vigo" | "santiago";
+
+export interface RegionConfig {
+ id: RegionId;
+ name: string;
+ stopsEndpoint: string;
+ estimatesEndpoint: string;
+ timetableEndpoint: string | null;
+ defaultCenter: [number, number]; // [lat, lng]
+ defaultZoom: number;
+ showMeters: boolean; // Whether to show distance in meters
+}
+
+export const REGIONS: Record<RegionId, RegionConfig> = {
+ vigo: {
+ id: "vigo",
+ name: "Vigo",
+ stopsEndpoint: "/stops/vigo.json",
+ estimatesEndpoint: "/api/vigo/GetStopEstimates",
+ timetableEndpoint: "/api/vigo/GetStopTimetable",
+ defaultCenter: [42.229188855975046, -8.72246955783102],
+ defaultZoom: 14,
+ showMeters: true,
+ },
+ santiago: {
+ id: "santiago",
+ name: "Santiago de Compostela",
+ stopsEndpoint: "/stops/santiago.json",
+ estimatesEndpoint: "/api/santiago/GetStopEstimates",
+ timetableEndpoint: null, // Not available for Santiago
+ defaultCenter: [42.8782, -8.5448],
+ defaultZoom: 14,
+ showMeters: false, // Santiago doesn't provide distance data
+ },
+};
+
+export const DEFAULT_REGION: RegionId = "vigo";
+
+export function getRegionConfig(regionId: RegionId): RegionConfig {
+ return REGIONS[regionId];
+}
+
+export function getAvailableRegions(): RegionConfig[] {
+ return Object.values(REGIONS);
+}
+
+export function isValidRegion(regionId: string): regionId is RegionId {
+ return regionId === "vigo" || regionId === "santiago";
+}
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 3959400..e49faaa 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,3 +1,5 @@
+import { type RegionId, getRegionConfig } from "./RegionConfig";
+
export interface CachedStopList {
timestamp: number;
data: Stop[];
@@ -17,48 +19,52 @@ export interface Stop {
favourite?: boolean;
}
-// In-memory cache and lookup map
-let cachedStops: Stop[] | null = null;
-let stopsMap: Record<number, Stop> = {};
-// Custom names loaded from localStorage
-let customNames: Record<number, string> = {};
+// In-memory cache and lookup map per region
+const cachedStopsByRegion: Record<string, Stop[] | null> = {};
+const stopsMapByRegion: Record<string, Record<number, Stop>> = {};
+// Custom names loaded from localStorage per region
+const customNamesByRegion: Record<string, Record<number, string>> = {};
-// Initialize cachedStops and customNames once
-async function initStops() {
- if (!cachedStops) {
- const response = await fetch("/stops.json");
+// Initialize cachedStops and customNames once per region
+async function initStops(region: RegionId) {
+ if (!cachedStopsByRegion[region]) {
+ const regionConfig = getRegionConfig(region);
+ const response = await fetch(regionConfig.stopsEndpoint);
const stops = (await response.json()) as Stop[];
// build array and map
- stopsMap = {};
- cachedStops = stops.map((stop) => {
+ stopsMapByRegion[region] = {};
+ cachedStopsByRegion[region] = stops.map((stop) => {
const entry = { ...stop, favourite: false } as Stop;
- stopsMap[stop.stopId] = entry;
+ stopsMapByRegion[region][stop.stopId] = entry;
return entry;
});
// load custom names
- const rawCustom = localStorage.getItem("customStopNames");
- if (rawCustom)
- customNames = JSON.parse(rawCustom) as Record<number, string>;
+ const rawCustom = localStorage.getItem(`customStopNames_${region}`);
+ if (rawCustom) {
+ customNamesByRegion[region] = JSON.parse(rawCustom) as Record<number, string>;
+ } else {
+ customNamesByRegion[region] = {};
+ }
}
}
-async function getStops(): Promise<Stop[]> {
- await initStops();
+async function getStops(region: RegionId): Promise<Stop[]> {
+ await initStops(region);
// update favourites
- const rawFav = localStorage.getItem("favouriteStops");
+ const rawFav = localStorage.getItem(`favouriteStops_${region}`);
const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
- cachedStops!.forEach(
+ cachedStopsByRegion[region]!.forEach(
(stop) => (stop.favourite = favouriteStops.includes(stop.stopId)),
);
- return cachedStops!;
+ return cachedStopsByRegion[region]!;
}
// New: get single stop by id
-async function getStopById(stopId: number): Promise<Stop | undefined> {
- await initStops();
- const stop = stopsMap[stopId];
+async function getStopById(region: RegionId, stopId: number): Promise<Stop | undefined> {
+ await initStops(region);
+ const stop = stopsMapByRegion[region]?.[stopId];
if (stop) {
- const rawFav = localStorage.getItem("favouriteStops");
+ const rawFav = localStorage.getItem(`favouriteStops_${region}`);
const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
stop.favourite = favouriteStops.includes(stopId);
}
@@ -66,30 +72,36 @@ async function getStopById(stopId: number): Promise<Stop | undefined> {
}
// Updated display name to include custom names
-function getDisplayName(stop: Stop): string {
+function getDisplayName(region: RegionId, stop: Stop): string {
+ const customNames = customNamesByRegion[region] || {};
if (customNames[stop.stopId]) return customNames[stop.stopId];
const nameObj = stop.name;
return nameObj.intersect || nameObj.original;
}
// New: set or remove custom names
-function setCustomName(stopId: number, label: string) {
- customNames[stopId] = label;
- localStorage.setItem("customStopNames", JSON.stringify(customNames));
+function setCustomName(region: RegionId, stopId: number, label: string) {
+ if (!customNamesByRegion[region]) {
+ customNamesByRegion[region] = {};
+ }
+ customNamesByRegion[region][stopId] = label;
+ localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region]));
}
-function removeCustomName(stopId: number) {
- delete customNames[stopId];
- localStorage.setItem("customStopNames", JSON.stringify(customNames));
+function removeCustomName(region: RegionId, stopId: number) {
+ if (customNamesByRegion[region]) {
+ delete customNamesByRegion[region][stopId];
+ localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region]));
+ }
}
// New: get custom label for a stop
-function getCustomName(stopId: number): string | undefined {
- return customNames[stopId];
+function getCustomName(region: RegionId, stopId: number): string | undefined {
+ return customNamesByRegion[region]?.[stopId];
}
-function addFavourite(stopId: number) {
- const rawFavouriteStops = localStorage.getItem("favouriteStops");
+function addFavourite(region: RegionId, stopId: number) {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
let favouriteStops: number[] = [];
if (rawFavouriteStops) {
favouriteStops = JSON.parse(rawFavouriteStops) as number[];
@@ -97,23 +109,23 @@ function addFavourite(stopId: number) {
if (!favouriteStops.includes(stopId)) {
favouriteStops.push(stopId);
- localStorage.setItem("favouriteStops", JSON.stringify(favouriteStops));
+ localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(favouriteStops));
}
}
-function removeFavourite(stopId: number) {
- const rawFavouriteStops = localStorage.getItem("favouriteStops");
+function removeFavourite(region: RegionId, stopId: number) {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
let favouriteStops: number[] = [];
if (rawFavouriteStops) {
favouriteStops = JSON.parse(rawFavouriteStops) as number[];
}
const newFavouriteStops = favouriteStops.filter((id) => id !== stopId);
- localStorage.setItem("favouriteStops", JSON.stringify(newFavouriteStops));
+ localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(newFavouriteStops));
}
-function isFavourite(stopId: number): boolean {
- const rawFavouriteStops = localStorage.getItem("favouriteStops");
+function isFavourite(region: RegionId, stopId: number): boolean {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
if (rawFavouriteStops) {
const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
return favouriteStops.includes(stopId);
@@ -123,8 +135,8 @@ function isFavourite(stopId: number): boolean {
const RECENT_STOPS_LIMIT = 10;
-function pushRecent(stopId: number) {
- const rawRecentStops = localStorage.getItem("recentStops");
+function pushRecent(region: RegionId, stopId: number) {
+ const rawRecentStops = localStorage.getItem(`recentStops_${region}`);
let recentStops: Set<number> = new Set();
if (rawRecentStops) {
recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
@@ -137,19 +149,19 @@ function pushRecent(stopId: number) {
recentStops.delete(val);
}
- localStorage.setItem("recentStops", JSON.stringify(Array.from(recentStops)));
+ localStorage.setItem(`recentStops_${region}`, JSON.stringify(Array.from(recentStops)));
}
-function getRecent(): number[] {
- const rawRecentStops = localStorage.getItem("recentStops");
+function getRecent(region: RegionId): number[] {
+ const rawRecentStops = localStorage.getItem(`recentStops_${region}`);
if (rawRecentStops) {
return JSON.parse(rawRecentStops) as number[];
}
return [];
}
-function getFavouriteIds(): number[] {
- const rawFavouriteStops = localStorage.getItem("favouriteStops");
+function getFavouriteIds(region: RegionId): number[] {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
if (rawFavouriteStops) {
return JSON.parse(rawFavouriteStops) as number[];
}
@@ -157,8 +169,9 @@ function getFavouriteIds(): number[] {
}
// New function to load stops from network
-async function loadStopsFromNetwork(): Promise<Stop[]> {
- const response = await fetch("/stops.json");
+async function loadStopsFromNetwork(region: RegionId): Promise<Stop[]> {
+ const regionConfig = getRegionConfig(region);
+ const response = await fetch(regionConfig.stopsEndpoint);
const stops = (await response.json()) as Stop[];
return stops.map((stop) => ({ ...stop, favourite: false } as Stop));
}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index dc45198..c48932c 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -13,6 +13,7 @@ import { TimetableSkeleton } from "../components/TimetableSkeleton";
import { ErrorDisplay } from "../components/ErrorDisplay";
import { PullToRefresh } from "../components/PullToRefresh";
import { useAutoRefresh } from "../hooks/useAutoRefresh";
+import { type RegionId, getRegionConfig } from "../data/RegionConfig";
export interface StopDetails {
stop: {
@@ -35,8 +36,9 @@ interface ErrorInfo {
message?: string;
}
-const loadData = async (stopId: string): Promise<StopDetails> => {
- const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
+const loadData = async (region: RegionId, stopId: string): Promise<StopDetails> => {
+ const regionConfig = getRegionConfig(region);
+ const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, {
headers: {
Accept: "application/json",
},
@@ -49,9 +51,16 @@ const loadData = async (stopId: string): Promise<StopDetails> => {
return await resp.json();
};
-const loadTimetableData = async (stopId: string): Promise<TimetableEntry[]> => {
+const loadTimetableData = async (region: RegionId, stopId: string): Promise<TimetableEntry[]> => {
+ const regionConfig = getRegionConfig(region);
+
+ // Check if timetable is available for this region
+ if (!regionConfig.timetableEndpoint) {
+ throw new Error("Timetable not available for this region");
+ }
+
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
- const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, {
+ const resp = await fetch(`${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, {
headers: {
Accept: "application/json",
},
@@ -83,7 +92,8 @@ export default function Estimates() {
const [favourited, setFavourited] = useState(false);
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
- const { tableStyle } = useApp();
+ const { tableStyle, region } = useApp();
+ const regionConfig = getRegionConfig(region);
const parseError = (error: any): ErrorInfo => {
if (!navigator.onLine) {
@@ -108,10 +118,10 @@ export default function Estimates() {
setEstimatesLoading(true);
setEstimatesError(null);
- const body = await loadData(params.id!);
+ const body = await loadData(region, params.id!);
setData(body);
setDataDate(new Date());
- setCustomName(StopDataProvider.getCustomName(stopIdNum));
+ setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
} catch (error) {
console.error('Error loading estimates data:', error);
setEstimatesError(parseError(error));
@@ -120,14 +130,20 @@ export default function Estimates() {
} finally {
setEstimatesLoading(false);
}
- }, [params.id, stopIdNum]);
+ }, [params.id, stopIdNum, region]);
const loadTimetableDataAsync = useCallback(async () => {
+ // Skip loading timetable if not available for this region
+ if (!regionConfig.timetableEndpoint) {
+ setTimetableLoading(false);
+ return;
+ }
+
try {
setTimetableLoading(true);
setTimetableError(null);
- const timetableBody = await loadTimetableData(params.id!);
+ const timetableBody = await loadTimetableData(region, params.id!);
setTimetableData(timetableBody);
} catch (error) {
console.error('Error loading timetable data:', error);
@@ -136,7 +152,7 @@ export default function Estimates() {
} finally {
setTimetableLoading(false);
}
- }, [params.id]);
+ }, [params.id, region, regionConfig.timetableEndpoint]);
const refreshData = useCallback(async () => {
await Promise.all([
@@ -168,16 +184,16 @@ export default function Estimates() {
loadEstimatesData();
loadTimetableDataAsync();
- StopDataProvider.pushRecent(parseInt(params.id ?? ""));
- setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? "")));
- }, [params.id, loadEstimatesData, loadTimetableDataAsync]);
+ StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
+ setFavourited(StopDataProvider.isFavourite(region, parseInt(params.id ?? "")));
+ }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]);
const toggleFavourite = () => {
if (favourited) {
- StopDataProvider.removeFavourite(stopIdNum);
+ StopDataProvider.removeFavourite(region, stopIdNum);
setFavourited(false);
} else {
- StopDataProvider.addFavourite(stopIdNum);
+ StopDataProvider.addFavourite(region, stopIdNum);
setFavourited(true);
}
};
@@ -188,10 +204,10 @@ export default function Estimates() {
if (input === null) return; // cancelled
const trimmed = input.trim();
if (trimmed === "") {
- StopDataProvider.removeCustomName(stopIdNum);
+ StopDataProvider.removeCustomName(region, stopIdNum);
setCustomName(undefined);
} else {
- StopDataProvider.setCustomName(stopIdNum, trimmed);
+ StopDataProvider.setCustomName(region, stopIdNum, trimmed);
setCustomName(trimmed);
}
};
@@ -270,9 +286,9 @@ export default function Estimates() {
/>
) : data ? (
tableStyle === "grouped" ? (
- <GroupedTable data={data} dataDate={dataDate} />
+ <GroupedTable data={data} dataDate={dataDate} regionConfig={regionConfig} />
) : (
- <RegularTable data={data} dataDate={dataDate} />
+ <RegularTable data={data} dataDate={dataDate} regionConfig={regionConfig} />
)
) : null}
</div>
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index 56a9c79..c3a1308 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -38,7 +38,7 @@ export default function StopMap() {
name: string;
} | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
- const { mapState, updateMapState, theme } = useApp();
+ const { mapState, updateMapState, theme, region } = useApp();
const mapRef = useRef<MapRef>(null);
const [mapStyleKey, setMapStyleKey] = useState<string>("light");
@@ -56,7 +56,7 @@ export default function StopMap() {
};
useEffect(() => {
- StopDataProvider.getStops().then((data) => {
+ StopDataProvider.getStops(region).then((data) => {
const features: GeoJsonFeature<
Point,
{ stopId: number; name: string; lines: string[] }
@@ -70,7 +70,7 @@ export default function StopMap() {
}));
setStops(features);
});
- }, []);
+ }, [region]);
useEffect(() => {
//const styleName = "carto";
@@ -115,7 +115,7 @@ export default function StopMap() {
const handlePointClick = (feature: any) => {
const props: any = feature.properties;
// fetch full stop to get lines array
- StopDataProvider.getStopById(props.stopId).then((stop) => {
+ StopDataProvider.getStopById(region, props.stopId).then((stop) => {
if (!stop) return;
setSelectedStop({
stopId: stop.stopId,
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index bcda311..eae6ad8 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -2,6 +2,7 @@ import { type Theme, useApp } from "../AppContext";
import "./settings.css";
import { useTranslation } from "react-i18next";
import { useState } from "react";
+import { getAvailableRegions } from "../data/RegionConfig";
export default function Settings() {
const { t, i18n } = useTranslation();
@@ -12,8 +13,12 @@ export default function Settings() {
setTableStyle,
mapPositionMode,
setMapPositionMode,
+ region,
+ setRegion,
} = useApp();
+ const regions = getAvailableRegions();
+
return (
<div className="page-container">
<h1 className="page-title">{t("about.title")}</h1>
@@ -21,6 +26,23 @@ export default function Settings() {
<section className="settings-section">
<h2>{t("about.settings")}</h2>
<div className="settings-content-inline">
+ <label htmlFor="region" className="form-label-inline">
+ Región:
+ </label>
+ <select
+ id="region"
+ className="form-select-inline"
+ value={region}
+ onChange={(e) => setRegion(e.target.value as any)}
+ >
+ {regions.map((r) => (
+ <option key={r.id} value={r.id}>
+ {r.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="settings-content-inline">
<label htmlFor="theme" className="form-label-inline">
{t("about.theme")}
</label>
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
index 8b0ebe2..13d3584 100644
--- a/src/frontend/app/routes/stoplist.tsx
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -5,9 +5,11 @@ import StopItemSkeleton from "../components/StopItemSkeleton";
import Fuse from "fuse.js";
import "./stoplist.css";
import { useTranslation } from "react-i18next";
+import { useApp } from "../AppContext";
export default function StopList() {
const { t } = useTranslation();
+ const { region } = useApp();
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
@@ -29,19 +31,19 @@ export default function StopList() {
// Load favourite and recent IDs immediately from localStorage
useEffect(() => {
- setFavouriteIds(StopDataProvider.getFavouriteIds());
- setRecentIds(StopDataProvider.getRecent());
- }, []);
+ setFavouriteIds(StopDataProvider.getFavouriteIds(region));
+ setRecentIds(StopDataProvider.getRecent(region));
+ }, [region]);
// Load stops from network
const loadStops = useCallback(async () => {
try {
setLoading(true);
- const stops = await StopDataProvider.loadStopsFromNetwork();
+ const stops = await StopDataProvider.loadStopsFromNetwork(region);
// Add favourite flags to stops
- const favouriteStopsIds = StopDataProvider.getFavouriteIds();
+ const favouriteStopsIds = StopDataProvider.getFavouriteIds(region);
const stopsWithFavourites = stops.map(stop => ({
...stop,
favourite: favouriteStopsIds.includes(stop.stopId)
@@ -55,7 +57,7 @@ export default function StopList() {
);
setFavouriteStops(favStops);
- const recIds = StopDataProvider.getRecent();
+ const recIds = StopDataProvider.getRecent(region);
const recStops = recIds
.map(id => stopsWithFavourites.find(stop => stop.stopId === id))
.filter(Boolean) as Stop[];
@@ -66,7 +68,7 @@ export default function StopList() {
} finally {
setLoading(false);
}
- }, []);
+ }, [region]);
useEffect(() => {
loadStops();
diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx
index cb55f53..1942ce8 100644
--- a/src/frontend/app/routes/timetable-$id.tsx
+++ b/src/frontend/app/routes/timetable-$id.tsx
@@ -7,6 +7,8 @@ import { TimetableSkeleton } from "../components/TimetableSkeleton";
import { ErrorDisplay } from "../components/ErrorDisplay";
import LineIcon from "../components/LineIcon";
import { useTranslation } from "react-i18next";
+import { type RegionId, getRegionConfig } from "../data/RegionConfig";
+import { useApp } from "../AppContext";
import "./timetable-$id.css";
interface ErrorInfo {
@@ -15,12 +17,19 @@ interface ErrorInfo {
message?: string;
}
-const loadTimetableData = async (stopId: string): Promise<TimetableEntry[]> => {
+const loadTimetableData = async (region: RegionId, stopId: string): Promise<TimetableEntry[]> => {
+ const regionConfig = getRegionConfig(region);
+
+ // Check if timetable is available for this region
+ if (!regionConfig.timetableEndpoint) {
+ throw new Error("Timetable not available for this region");
+ }
+
// Add delay to see skeletons in action (remove in production)
await new Promise(resolve => setTimeout(resolve, 1000));
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
- const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, {
+ const resp = await fetch(`${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, {
headers: {
Accept: "application/json",
},
@@ -99,6 +108,7 @@ const parseServiceId = (serviceId: string): string => {
export default function Timetable() {
const { t } = useTranslation();
+ const { region } = useApp();
const params = useParams();
const stopIdNum = parseInt(params.id ?? "");
const [timetableData, setTimetableData] = useState<TimetableEntry[]>([]);
@@ -107,6 +117,7 @@ export default function Timetable() {
const [error, setError] = useState<ErrorInfo | null>(null);
const [showPastEntries, setShowPastEntries] = useState(false);
const nextEntryRef = useRef<HTMLDivElement>(null);
+ const regionConfig = getRegionConfig(region);
const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS
const filteredData = filterTimetableData(timetableData, currentTime, showPastEntries);
@@ -130,11 +141,22 @@ export default function Timetable() {
};
const loadData = async () => {
+ // Check if timetable is available for this region
+ if (!regionConfig.timetableEndpoint) {
+ setError({
+ type: 'server',
+ status: 501,
+ message: 'Timetable not available for this region'
+ });
+ setLoading(false);
+ return;
+ }
+
try {
setLoading(true);
setError(null);
- const timetableBody = await loadTimetableData(params.id!);
+ const timetableBody = await loadTimetableData(region, params.id!);
setTimetableData(timetableBody);
if (timetableBody.length > 0) {
@@ -168,8 +190,8 @@ export default function Timetable() {
useEffect(() => {
loadData();
- setCustomName(StopDataProvider.getCustomName(stopIdNum));
- }, [params.id]);
+ setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
+ }, [params.id, region]);
if (loading) {
return (
@@ -266,6 +288,7 @@ const TimetableTableWithScroll: React.FC<{
nextEntryRef: React.RefObject<HTMLDivElement | null>;
}> = ({ data, showAll, currentTime, nextEntryRef }) => {
const { t } = useTranslation();
+ const { region } = useApp();
const nowMinutes = timeToMinutes(currentTime);
return (
@@ -295,7 +318,7 @@ const TimetableTableWithScroll: React.FC<{
>
<div className="card-header">
<div className="line-info">
- <LineIcon line={entry.line.name} />
+ <LineIcon line={entry.line.name} region={region} />
</div>
<div className="destination-info">