diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-10-21 17:38:01 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-10-21 17:38:01 +0200 |
| commit | 12ecc97b07093f3cac6567c70ff75d57b429c674 (patch) | |
| tree | cf4ec0abe4e1d20c01c62e0fc04af5eaa885e881 /src/frontend/app | |
| parent | 67c1dd5cb0025235c29ebd1f1706e5c17392dbff (diff) | |
Implement new Santiago region (WIP)
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/AppContext.tsx | 29 | ||||
| -rw-r--r-- | src/frontend/app/components/GroupedTable.tsx | 24 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 295 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.tsx | 17 | ||||
| -rw-r--r-- | src/frontend/app/components/RegionSelector.tsx | 33 | ||||
| -rw-r--r-- | src/frontend/app/components/RegularTable.tsx | 23 | ||||
| -rw-r--r-- | src/frontend/app/components/StopItem.tsx | 7 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.tsx | 17 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableTable.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/data/RegionConfig.ts | 49 | ||||
| -rw-r--r-- | src/frontend/app/data/StopDataProvider.ts | 113 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 54 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 8 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 22 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 16 | ||||
| -rw-r--r-- | src/frontend/app/routes/timetable-$id.tsx | 35 |
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"> |
