aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/settings.json29
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs34
-rw-r--r--src/frontend/app/components/GroupedTable.tsx90
-rw-r--r--src/frontend/app/components/LineIcon.css12
-rw-r--r--src/frontend/app/components/LineIcon.tsx20
-rw-r--r--src/frontend/app/components/PullToRefresh.css2
-rw-r--r--src/frontend/app/components/PullToRefresh.tsx13
-rw-r--r--src/frontend/app/components/RegularTable.tsx94
-rw-r--r--src/frontend/app/components/SchedulesTable.css208
-rw-r--r--src/frontend/app/components/SchedulesTable.tsx213
-rw-r--r--src/frontend/app/components/SchedulesTableSkeleton.tsx136
-rw-r--r--src/frontend/app/components/StopGalleryItem.tsx12
-rw-r--r--src/frontend/app/components/StopItem.tsx9
-rw-r--r--src/frontend/app/components/StopMapModal.tsx32
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx38
-rw-r--r--src/frontend/app/components/StopSheet.tsx84
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.css10
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx37
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx4
-rw-r--r--src/frontend/app/components/layout/Header.tsx1
-rw-r--r--src/frontend/app/components/ui/Button.css39
-rw-r--r--src/frontend/app/components/ui/Button.tsx25
-rw-r--r--src/frontend/app/components/ui/PageContainer.css20
-rw-r--r--src/frontend/app/components/ui/PageContainer.tsx14
-rw-r--r--src/frontend/app/config/RegionConfig.ts69
-rw-r--r--src/frontend/app/contexts/MapContext.tsx35
-rw-r--r--src/frontend/app/contexts/SettingsContext.tsx46
-rw-r--r--src/frontend/app/data/LineColors.ts13
-rw-r--r--src/frontend/app/data/StopDataProvider.ts103
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json26
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json26
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json26
-rw-r--r--src/frontend/app/routes.tsx2
-rw-r--r--src/frontend/app/routes/estimates-$id.css270
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx374
-rw-r--r--src/frontend/app/routes/home.tsx20
-rw-r--r--src/frontend/app/routes/map.tsx42
-rw-r--r--src/frontend/app/routes/settings.tsx132
-rw-r--r--src/frontend/app/routes/stops-$id.css2
-rw-r--r--src/frontend/app/routes/stops-$id.tsx48
-rw-r--r--src/frontend/app/routes/timetable-$id.css224
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx570
-rw-r--r--src/frontend/app/tailwind.css6
-rw-r--r--src/frontend/package-lock.json8
-rw-r--r--src/frontend/package.json2
45 files changed, 328 insertions, 2892 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json
index bef30cb..360b8b3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,33 +5,9 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
- "[typescript]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[typescriptreact]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[javascript]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[javascriptreact]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
- "[jsonc]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[css]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[html]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "[markdown]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
"[python]": {
"editor.defaultFormatter": "ms-python.autopep8",
"editor.codeActionsOnSave": {
@@ -60,5 +36,8 @@
"typescript.enablePromptUseWorkspaceTsdk": true,
"css.customData": [
".vscode/tailwind.json"
- ]
+ ],
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "vscode.typescript-language-features"
+ }
}
diff --git a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs
index 788634d..986ded4 100644
--- a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs
+++ b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs
@@ -8,42 +8,76 @@ public class LineFormatterService
{
circulation.Route = circulation.Route.Replace("*", "");
+ if (circulation.Route == "FORA DE SERVIZO.G.B.")
+ {
+ circulation.Route = "García Barbón, 7 (fora de servizo)";
+ }
+
if (circulation.Line == "18A")
{
circulation.Route = circulation.Route
.Replace("\"A\" ", "")
.Trim()
.Replace("SARDOMA por MANTELAS", "Praza de Miraflores");
+ return circulation;
}
if (circulation.Line == "5A")
{
circulation.Route = circulation.Route
.Replace("Rúa da Travesía de Vigo, 220", "URZAIZ - TVA DE VIGO");
+ return circulation;
}
if (circulation.Line == "5B")
{
circulation.Route = circulation.Route
.Replace("Rúa de Sanjurjo Badía, 252", "S. BADIA - TVA DE VIGO");
+ return circulation;
}
if (circulation.Line == "11")
{
circulation.Route = circulation.Route
.Replace("Avda. de Cesáreo Vázquez, 61", "SAN MIGUEL por FLORIDA");
+ return circulation;
}
if (circulation.Line == "4C")
{
circulation.Route = circulation.Route
.Replace("Rúa do Porriño (fronte 9)", "COIA POR CASTELAO");
+ return circulation;
}
if (circulation.Line == "6")
{
circulation.Route = circulation.Route
.Replace("\"", "");
+ return circulation;
+ }
+
+ if (circulation.Line == "FUT")
+ {
+ if (circulation.Route == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO")
+ {
+ circulation.Line = "MAR";
+ circulation.Route = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO";
+ }
+
+ if (circulation.Route == "P. ESPAÑA-T.VIGO-S.BADÍA")
+ {
+ circulation.Line = "RIO";
+ circulation.Route = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA";
+ }
+
+ if (circulation.Route == "NAVIA-BOUZAS-URZAIZ-G. ESPINO")
+ {
+ circulation.Line = "GOL";
+ circulation.Route = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO";
+ }
+
+ return circulation;
}
return circulation;
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
deleted file mode 100644
index 175899a..0000000
--- a/src/frontend/app/components/GroupedTable.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { type RegionConfig } from "../config/RegionConfig";
-import { type Estimate } from "../routes/estimates-$id";
-import LineIcon from "./LineIcon";
-
-interface GroupedTable {
- data: Estimate[];
- dataDate: Date | null;
- regionConfig: RegionConfig;
-}
-
-export const GroupedTable: React.FC<GroupedTable> = ({
- data,
- dataDate,
- regionConfig,
-}) => {
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} m`;
- }
- };
-
- const groupedEstimates = data.reduce(
- (acc, estimate) => {
- if (!acc[estimate.line]) {
- acc[estimate.line] = [];
- }
- acc[estimate.line].push(estimate);
- return acc;
- },
- {} as Record<string, typeof data>
- );
-
- const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
- const firstArrivalA = groupedEstimates[a][0].minutes;
- const firstArrivalB = groupedEstimates[b][0].minutes;
- return firstArrivalA - firstArrivalB;
- });
-
- return (
- <table className="table">
- <caption>
- Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
- </caption>
-
- <thead>
- <tr>
- <th>Línea</th>
- <th>Ruta</th>
- <th>Llegada</th>
- {regionConfig.showMeters && <th>Distancia</th>}
- </tr>
- </thead>
-
- <tbody>
- {sortedLines.map((line) =>
- groupedEstimates[line].map((estimate, idx) => (
- <tr key={`${line}-${idx}`}>
- {idx === 0 && (
- <td rowSpan={groupedEstimates[line].length}>
- <LineIcon line={line} region={regionConfig.id} />
- </td>
- )}
- <td>{estimate.route}</td>
- <td>{`${estimate.minutes} min`}</td>
- {regionConfig.showMeters && (
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : "No disponible"}
- </td>
- )}
- </tr>
- ))
- )}
- </tbody>
-
- {data?.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={regionConfig.showMeters ? 4 : 3}>
- No hay estimaciones disponibles
- </td>
- </tr>
- </tfoot>
- )}
- </table>
- );
-};
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index 01ff2bd..89f8bdb 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -71,6 +71,18 @@
--line-u2-text: hsl(0, 0%, 100%);
--line-vts: hsl(300, 33%, 30%);
--line-vts-text: hsl(0, 0%, 100%);
+
+ /* Special christmas line - Touristic bus */
+ --line-nad: hsl(0, 100%, 40%);
+ --line-nad-text: hsl(0, 0%, 100%);
+
+ --line-mar: hsl(208, 68%, 66%);
+ --line-mar-text: hsl(0, 0%, 100%);
+ --line-rio: hsl(208, 68%, 66%);
+ --line-rio-text: hsl(0, 0%, 100%);
+ --line-gol: hsl(208, 68%, 66%);
+ --line-gol-text: hsl(0, 0%, 100%);
+
}
.line-icon-default {
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
index 5ccf80a..fc40824 100644
--- a/src/frontend/app/components/LineIcon.tsx
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -1,25 +1,23 @@
import React, { useMemo } from "react";
-import { type RegionId } from "../config/RegionConfig";
import "./LineIcon.css";
interface LineIconProps {
line: string;
-
- /**
- * @deprecated Unused since region is only Vigo
- */
- region?: RegionId;
-
- mode?: "rounded"|"pill"|"default";
+ mode?: "rounded" | "pill" | "default";
}
const LineIcon: React.FC<LineIconProps> = ({
line,
mode = "default",
}) => {
+ const actualLine = useMemo(() => {
+ return line.trim().replace('510', 'NAD');
+ }, [line])
+
const formattedLine = useMemo(() => {
- return /^[a-zA-Z]/.test(line) ? line : `L${line}`;
- }, [line]);
+ return /^[a-zA-Z]/.test(actualLine) ? actualLine : `L${actualLine}`;
+ }, [actualLine]);
+
const cssVarName = `--line-${formattedLine.toLowerCase()}`;
const cssTextVarName = `--line-${formattedLine.toLowerCase()}-text`;
@@ -33,7 +31,7 @@ const LineIcon: React.FC<LineIconProps> = ({
} as React.CSSProperties
}
>
- {line}
+ {actualLine}
</span>
);
};
diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css
index d4946d2..3e8f802 100644
--- a/src/frontend/app/components/PullToRefresh.css
+++ b/src/frontend/app/components/PullToRefresh.css
@@ -6,7 +6,7 @@
.pull-to-refresh-indicator {
position: fixed;
- top: 20px;
+ top: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx
index b3abe86..2de1a4f 100644
--- a/src/frontend/app/components/PullToRefresh.tsx
+++ b/src/frontend/app/components/PullToRefresh.tsx
@@ -1,6 +1,6 @@
-import React, { useRef, useState, useEffect, useCallback } from "react";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { RefreshCw } from "lucide-react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import "./PullToRefresh.css";
interface PullToRefreshProps {
@@ -26,15 +26,6 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
const scale = useTransform(y, [0, threshold], [0.5, 1]);
const rotate = useTransform(y, [0, threshold], [0, 180]);
- const isAtPageTop = useCallback(() => {
- const scrollTop =
- window.pageYOffset ||
- document.documentElement.scrollTop ||
- document.body.scrollTop ||
- 0;
- return scrollTop <= 10; // Increased tolerance to 10px
- }, []);
-
const handleTouchStart = useCallback(
(e: TouchEvent) => {
// Very strict check - must be at absolute top
@@ -160,7 +151,6 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
return (
<div className="pull-to-refresh-container" ref={containerRef}>
- {/* Simple indicator */}
{isPulling && (
<motion.div className="pull-to-refresh-indicator" style={{ opacity }}>
<motion.div
@@ -174,7 +164,6 @@ export const PullToRefresh: React.FC<PullToRefreshProps> = ({
</motion.div>
)}
- {/* Normal content - no transform interference */}
<div className="pull-to-refresh-content">{children}</div>
</div>
);
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
deleted file mode 100644
index a738d03..0000000
--- a/src/frontend/app/components/RegularTable.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { useTranslation } from "react-i18next";
-import { type RegionConfig } from "../config/RegionConfig";
-import { type Estimate } from "../routes/estimates-$id";
-import LineIcon from "./LineIcon";
-
-interface RegularTableProps {
- data: Estimate[];
- dataDate: Date | null;
- regionConfig: RegionConfig;
-}
-
-export const RegularTable: React.FC<RegularTableProps> = ({
- data,
- dataDate,
- regionConfig,
-}) => {
- const { t } = useTranslation();
-
- const absoluteArrivalTime = (minutes: number) => {
- const now = new Date();
- const arrival = new Date(now.getTime() + minutes * 60000);
- return Intl.DateTimeFormat(
- typeof navigator !== "undefined" ? navigator.language : "en",
- {
- hour: "2-digit",
- minute: "2-digit",
- }
- ).format(arrival);
- };
-
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} ${t("estimates.meters", "m")}`;
- }
- };
-
- return (
- <table className="table">
- <caption>
- {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", {
- time: dataDate?.toLocaleTimeString(),
- })}
- </caption>
-
- <thead>
- <tr>
- <th>{t("estimates.line", "Línea")}</th>
- <th>{t("estimates.route", "Ruta")}</th>
- <th>{t("estimates.arrival", "Llegada")}</th>
- {regionConfig.showMeters && (
- <th>{t("estimates.distance", "Distancia")}</th>
- )}
- </tr>
- </thead>
-
- <tbody>
- {data
- .sort((a, b) => a.minutes - b.minutes)
- .map((estimate, idx) => (
- <tr key={idx}>
- <td>
- <LineIcon line={estimate.line} region={regionConfig.id} />
- </td>
- <td>{estimate.route}</td>
- <td>
- {estimate.minutes > 15
- ? absoluteArrivalTime(estimate.minutes)
- : `${estimate.minutes} ${t("estimates.minutes", "min")}`}
- </td>
- {regionConfig.showMeters && (
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : t("estimates.not_available", "No disponible")}
- </td>
- )}
- </tr>
- ))}
- </tbody>
-
- {data?.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={regionConfig.showMeters ? 4 : 3}>
- {t("estimates.none", "No hay estimaciones disponibles")}
- </td>
- </tr>
- </tfoot>
- )}
- </table>
- );
-};
diff --git a/src/frontend/app/components/SchedulesTable.css b/src/frontend/app/components/SchedulesTable.css
deleted file mode 100644
index c0c5cb7..0000000
--- a/src/frontend/app/components/SchedulesTable.css
+++ /dev/null
@@ -1,208 +0,0 @@
-.timetable-container {
- margin-top: 2rem;
-}
-
-.timetable-caption {
- font-weight: bold;
- margin-bottom: 1rem;
- text-align: left;
- font-size: 1.1rem;
- color: var(--text-primary);
-}
-
-.timetable-cards {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- margin-bottom: 1rem;
-}
-
-.timetable-card {
- background-color: var(--surface-future);
- border: 1px solid var(--card-border);
- border-radius: 10px;
- padding: 1.25rem;
- transition:
- background-color 0.2s ease,
- border 0.2s ease;
-}
-
-/* Next upcoming service: slight emphasis */
-.timetable-card.timetable-next {
- background-color: var(--surface-next);
- border-color: var(--card-border);
- position: relative;
-}
-
-.timetable-card.timetable-next::before {
- content: "";
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 4px;
- border-top-left-radius: 10px;
- border-bottom-left-radius: 10px;
- background: var(--accent-next);
-}
-
-.timetable-card.timetable-past {
- background-color: var(--surface-past);
- color: var(--text-secondary);
- border: 1px solid var(--card-border);
-}
-
-.timetable-card .card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
-}
-
-.timetable-card .line-info {
- flex-shrink: 0;
-}
-
-.timetable-card .destination-info {
- flex: 1;
- text-align: left;
- margin: 0 1rem;
- color: var(--text-primary);
-}
-
-.timetable-card .destination-info strong {
- font-size: 0.95rem;
-}
-
-.timetable-card.timetable-past .destination-info {
- color: var(--text-secondary);
-}
-
-.timetable-card .time-info {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- flex-shrink: 0;
-}
-
-.timetable-card .timetable-card .departure-time {
- font-weight: bold;
- font-family: monospace;
- font-size: 1.1rem;
- color: var(--text-primary);
-}
-
-.timetable-card.timetable-past .departure-time {
- color: var(--text-secondary);
-}
-
-.timetable-card .card-body {
- line-height: 1.4;
-}
-
-.timetable-card .route-streets {
- font-size: 0.85rem;
- color: var(--text-secondary);
- line-height: 1.8;
- word-break: break-word;
-}
-
-.timetable-card .timetable-card .service-id {
- font-family: monospace;
- font-size: 0.8rem;
- color: var(--text-secondary);
- background: var(--service-background);
- padding: 0.15rem 0.4rem;
- border-radius: 3px;
- font-weight: 500;
- display: inline;
- margin-right: 0.2em;
-}
-
-.timetable-card.timetable-past .service-id {
- color: var(--text-secondary);
- background: var(--service-background-past);
-}
-
-.timetable-container .no-data {
- text-align: center;
- color: var(--text-secondary);
- font-style: italic;
- padding: 2rem;
- background: var(--card-background);
- border-radius: 8px;
- border: 1px solid var(--card-border);
-}
-
-/* Responsive design */
-@media (max-width: 768px) {
- .timetable-cards {
- gap: 0.5rem;
- }
- .timetable-card {
- padding: 0.75rem;
- }
- .timetable-card .card-header {
- margin-bottom: 0.5rem;
- }
- .timetable-card .destination-info {
- margin: 0 0.5rem;
- }
- .timetable-card .destination-info strong {
- font-size: 0.9rem;
- }
- .timetable-card .departure-time {
- font-size: 1rem;
- }
- .timetable-card .service-id {
- font-size: 0.8rem;
- padding: 0.2rem 0.4rem;
- }
-}
-
-@media (max-width: 480px) {
- .timetable-card .card-header {
- flex-direction: column;
- align-items: stretch;
- gap: 0.5rem;
- }
-
- .timetable-card .destination-info {
- text-align: left;
- margin: 0;
- order: 2;
- }
-
- .timetable-card .time-info {
- align-items: flex-start;
- order: 1;
- align-self: flex-end;
- }
-
- .timetable-card .line-info {
- order: 0;
- align-self: flex-start;
- }
-
- /* Create a flex container for line and time on mobile */
- .timetable-card .card-header {
- position: relative;
- }
-
- .timetable-card .line-info {
- position: absolute;
- left: 0;
- top: 0;
- }
-
- .timetable-card .time-info {
- position: absolute;
- right: 0;
- top: 0;
- }
-
- .timetable-card .destination-info {
- margin-top: 2rem;
- text-align: left;
- }
-}
diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx
deleted file mode 100644
index faf7b01..0000000
--- a/src/frontend/app/components/SchedulesTable.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { useTranslation } from "react-i18next";
-import { useApp } from "~/AppContext";
-import LineIcon from "./LineIcon";
-import "./SchedulesTable.css";
-
-export type ScheduledTable = {
- trip_id: string;
- service_id: string;
-
- line: string;
- route: string;
-
- stop_sequence: number;
- shape_dist_traveled: number;
- next_streets: string[];
-
- starting_code: string;
- starting_name: string;
- starting_time: string;
-
- calling_time: string;
- calling_ssm: number;
-
- terminus_code: string;
- terminus_name: string;
- terminus_time: string;
-};
-
-interface TimetableTableProps {
- data: ScheduledTable[];
- showAll?: boolean;
- currentTime?: string; // HH:MM:SS format
-}
-
-// Utility function to parse service ID and get the turn number
-const parseServiceId = (serviceId: string): string => {
- const parts = serviceId.split("_");
- if (parts.length === 0) return "";
-
- const lastPart = parts[parts.length - 1];
- if (lastPart.length < 6) return "";
-
- const last6 = lastPart.slice(-6);
- const lineCode = last6.slice(0, 3);
- const turnCode = last6.slice(-3);
-
- // Remove leading zeros from turn
- const turnNumber = parseInt(turnCode, 10).toString();
-
- // Parse line number with special cases
- const lineNumber = parseInt(lineCode, 10);
- let displayLine: string;
-
- switch (lineNumber) {
- case 1:
- displayLine = "C1";
- break;
- case 3:
- displayLine = "C3";
- break;
- case 30:
- displayLine = "N1";
- break;
- case 33:
- displayLine = "N4";
- break;
- case 8:
- displayLine = "A";
- break;
- case 101:
- displayLine = "H";
- break;
- case 150:
- displayLine = "REF";
- break;
- case 201:
- displayLine = "U1";
- break;
- case 202:
- displayLine = "U2";
- break;
- case 500:
- displayLine = "TUR";
- break;
- default:
- displayLine = `L${lineNumber}`;
- }
-
- return `${displayLine}-${turnNumber}`;
-};
-
-// Utility function to compare times
-const timeToMinutes = (time: string): number => {
- const [hours, minutes] = time.split(":").map(Number);
- return hours * 60 + minutes;
-};
-
-// Utility function to format GTFS time for display (handle hours >= 24)
-const formatTimeForDisplay = (time: string): string => {
- const [hours, minutes] = time.split(":").map(Number);
- const normalizedHours = hours % 24;
- return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
-};
-
-// Utility function to find nearby entries
-const findNearbyEntries = (
- entries: ScheduledTable[],
- currentTime: string,
- before: number = 4,
- after: number = 4
-): ScheduledTable[] => {
- if (!currentTime) return entries.slice(0, before + after);
-
- const currentMinutes = timeToMinutes(currentTime);
- const sortedEntries = [...entries].sort(
- (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
- );
-
- let currentIndex = sortedEntries.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
- );
-
- if (currentIndex === -1) {
- // All entries are before current time, show last ones
- return sortedEntries.slice(-before - after);
- }
-
- const startIndex = Math.max(0, currentIndex - before);
- const endIndex = Math.min(sortedEntries.length, currentIndex + after);
-
- return sortedEntries.slice(startIndex, endIndex);
-};
-
-export const SchedulesTable: React.FC<TimetableTableProps> = ({
- data,
- showAll = false,
- 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));
-
- return (
- <div className="timetable-container">
- <div className="timetable-caption">
- {showAll
- ? t("timetable.fullCaption", "Horarios teóricos de la parada")
- : t("timetable.nearbyCaption", "Próximos horarios teóricos")}
- </div>
-
- <div className="timetable-cards">
- {displayData.map((entry, index) => {
- const entryMinutes = timeToMinutes(entry.calling_time);
- const isPast = entryMinutes < nowMinutes;
- return (
- <div
- key={`${entry.trip_id}-${index}`}
- className={`timetable-card${isPast ? " timetable-past" : ""}`}
- style={{
- background: isPast
- ? "var(--surface-past, #f3f3f3)"
- : "var(--surface-future, #fff)",
- }}
- >
- <div className="card-header">
- <div className="line-info">
- <LineIcon line={entry.line} region={region} />
- </div>
-
- <div className="destination-info">
- {entry.route && entry.route.trim() ? (
- <strong>{entry.route}</strong>
- ) : (
- <strong>
- {t("timetable.noDestination", "Línea")} {entry.line}
- </strong>
- )}
- </div>
-
- <div className="time-info">
- <span className="departure-time">
- {formatTimeForDisplay(entry.calling_time)}
- </span>
- </div>
- </div>
- <div className="card-body">
- <div className="route-streets">
- <span className="service-id">
- {parseServiceId(entry.service_id)}
- </span>
- {entry.next_streets.length > 0 && (
- <span> — {entry.next_streets.join(" — ")}</span>
- )}
- </div>
- </div>
- </div>
- );
- })}
- </div>
- {displayData.length === 0 && (
- <p className="no-data">
- {t("timetable.noData", "No hay datos de horarios disponibles")}
- </p>
- )}
- </div>
- );
-};
diff --git a/src/frontend/app/components/SchedulesTableSkeleton.tsx b/src/frontend/app/components/SchedulesTableSkeleton.tsx
deleted file mode 100644
index 3ae9729..0000000
--- a/src/frontend/app/components/SchedulesTableSkeleton.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import React from "react";
-import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
-import "react-loading-skeleton/dist/skeleton.css";
-import { useTranslation } from "react-i18next";
-
-interface EstimatesTableSkeletonProps {
- rows?: number;
-}
-
-export const SchedulesTableSkeleton: React.FC<EstimatesTableSkeletonProps> = ({
- rows = 3,
-}) => {
- const { t } = useTranslation();
-
- return (
- <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0">
- <table className="table">
- <caption>
- <Skeleton width="250px" />
- </caption>
-
- <thead>
- <tr>
- <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>
- </tr>
- </thead>
-
- <tbody>
- {Array.from({ length: rows }, (_, index) => (
- <tr key={`skeleton-${index}`}>
- <td>
- <Skeleton
- width="40px"
- height="24px"
- style={{ borderRadius: "4px" }}
- />
- </td>
- <td>
- <Skeleton width="120px" />
- </td>
- <td>
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- gap: "2px",
- }}
- >
- <Skeleton width="60px" />
- <Skeleton width="40px" />
- </div>
- </td>
- <td>
- <Skeleton width="50px" />
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </SkeletonTheme>
- );
-};
-
-interface EstimatesGroupedSkeletonProps {
- groups?: number;
- rowsPerGroup?: number;
-}
-
-export const EstimatesGroupedSkeleton: React.FC<
- EstimatesGroupedSkeletonProps
-> = ({ groups = 3, rowsPerGroup = 2 }) => {
- const { t } = useTranslation();
-
- return (
- <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0">
- <table className="table grouped-table">
- <caption>
- <Skeleton width="250px" />
- </caption>
-
- <thead>
- <tr>
- <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>
- </tr>
- </thead>
-
- <tbody>
- {Array.from({ length: groups }, (_, groupIndex) => (
- <React.Fragment key={`group-${groupIndex}`}>
- {Array.from({ length: rowsPerGroup }, (_, rowIndex) => (
- <tr
- key={`skeleton-${groupIndex}-${rowIndex}`}
- className={rowIndex === 0 ? "group-start" : ""}
- >
- <td>
- {rowIndex === 0 && (
- <Skeleton
- width="40px"
- height="24px"
- style={{ borderRadius: "4px" }}
- />
- )}
- </td>
- <td>
- <Skeleton width="120px" />
- </td>
- <td>
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- gap: "2px",
- }}
- >
- <Skeleton width="60px" />
- <Skeleton width="40px" />
- </div>
- </td>
- <td>
- <Skeleton width="50px" />
- </td>
- </tr>
- ))}
- </React.Fragment>
- ))}
- </tbody>
- </table>
- </SkeletonTheme>
- );
-};
diff --git a/src/frontend/app/components/StopGalleryItem.tsx b/src/frontend/app/components/StopGalleryItem.tsx
index b32661a..72a13e5 100644
--- a/src/frontend/app/components/StopGalleryItem.tsx
+++ b/src/frontend/app/components/StopGalleryItem.tsx
@@ -1,30 +1,26 @@
import React from "react";
import { Link } from "react-router";
-import { type Stop } from "../data/StopDataProvider";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import LineIcon from "./LineIcon";
-import { useApp } from "../AppContext";
-import StopDataProvider from "../data/StopDataProvider";
interface StopGalleryItemProps {
stop: Stop;
}
const StopGalleryItem: React.FC<StopGalleryItemProps> = ({ stop }) => {
- const { region } = useApp();
-
return (
<div className="gallery-item">
- <Link className="gallery-item-link" to={`/estimates/${stop.stopId}`}>
+ <Link className="gallery-item-link" to={`/stops/${stop.stopId}`}>
<div className="gallery-item-header">
{stop.favourite && <span className="favourite-icon">★</span>}
<span className="gallery-item-code">({stop.stopId})</span>
</div>
<div className="gallery-item-name">
- {StopDataProvider.getDisplayName(region, stop)}
+ {StopDataProvider.getDisplayName(stop)}
</div>
<div className="gallery-item-lines">
{stop.lines?.slice(0, 5).map((line) => (
- <LineIcon key={line} line={line} region={region} />
+ <LineIcon key={line} line={line} />
))}
{stop.lines && stop.lines.length > 5 && (
<span className="more-lines">+{stop.lines.length - 5}</span>
diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx
index de51576..7875b59 100644
--- a/src/frontend/app/components/StopItem.tsx
+++ b/src/frontend/app/components/StopItem.tsx
@@ -1,6 +1,5 @@
import React from "react";
import { Link } from "react-router";
-import { useApp } from "../AppContext";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import LineIcon from "./LineIcon";
@@ -9,15 +8,13 @@ interface StopItemProps {
}
const StopItem: React.FC<StopItemProps> = ({ stop }) => {
- const { region } = useApp();
-
return (
<li className="list-item">
- <Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
+ <Link className="list-item-link" to={`/stops/${stop.stopId}`}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span style={{ fontWeight: 600 }}>
{stop.favourite && <span className="favourite-icon">★</span>}
- {StopDataProvider.getDisplayName(region, stop)}
+ {StopDataProvider.getDisplayName(stop)}
</span>
<span style={{ fontSize: "0.85em", color: "var(--subtitle-color)", marginLeft: "0.5rem" }}>
({stop.stopId})
@@ -25,7 +22,7 @@ const StopItem: React.FC<StopItemProps> = ({ stop }) => {
</div>
<div className="line-icons" style={{ marginTop: "0.25rem" }}>
{stop.lines?.map((line) => (
- <LineIcon key={line} line={line} region={region} />
+ <LineIcon key={line} line={line} />
))}
</div>
</Link>
diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx
index 74f20d9..55ad848 100644
--- a/src/frontend/app/components/StopMapModal.tsx
+++ b/src/frontend/app/components/StopMapModal.tsx
@@ -1,15 +1,15 @@
import maplibregl from "maplibre-gl";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Map, {
- Layer,
- Marker,
- Source,
- type MapRef
+ Layer,
+ Marker,
+ Source,
+ type MapRef
} from "react-map-gl/maplibre";
import { Sheet } from "react-modal-sheet";
import { useApp } from "~/AppContext";
-import { getRegionConfig, type RegionId } from "~/config/RegionConfig";
-import { getLineColor } from "~/data/LineColors";
+import { REGION_DATA } from "~/config/RegionConfig";
+import { getLineColour } from "~/data/LineColors";
import type { Stop } from "~/data/StopDataProvider";
import { loadStyle } from "~/maps/styleloader";
import "./StopMapModal.css";
@@ -37,7 +37,6 @@ export interface ConsolidatedCirculationForMap {
interface StopMapModalProps {
stop: Stop;
circulations: ConsolidatedCirculationForMap[];
- region: RegionId;
isOpen: boolean;
onClose: () => void;
selectedCirculationId?: string;
@@ -46,7 +45,6 @@ interface StopMapModalProps {
export const StopMapModal: React.FC<StopMapModalProps> = ({
stop,
circulations,
- region,
isOpen,
onClose,
selectedCirculationId,
@@ -59,8 +57,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
const [shapeData, setShapeData] = useState<any | null>(null);
const [previousShapeData, setPreviousShapeData] = useState<any | null>(null);
- const regionConfig = getRegionConfig(region);
-
// Filter circulations that have GPS coordinates
const busesWithPosition = useMemo(
() => circulations.filter((c) => !!c.currentPosition),
@@ -165,7 +161,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
maxZoom: 17,
} as any);
}
- } catch {}
+ } catch { }
}, [stop, selectedBus, shapeData, previousShapeData]);
// Load style without traffic layers for the stop map
@@ -246,7 +242,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
!selectedBus ||
!selectedBus.schedule?.shapeId ||
selectedBus.currentPosition?.shapeIndex === undefined ||
- !regionConfig.shapeEndpoint
+ !REGION_DATA.shapeEndpoint
) {
setShapeData(null);
setPreviousShapeData(null);
@@ -266,7 +262,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
sLat?: number,
sLon?: number
) => {
- let url = `${regionConfig.shapeEndpoint}?shapeId=${sId}`;
+ let url = `${REGION_DATA.shapeEndpoint}?shapeId=${sId}`;
if (bIndex !== undefined) url += `&busShapeIndex=${bIndex}`;
if (sIndex !== undefined) url += `&stopShapeIndex=${sIndex}`;
else if (sLat && sLon) url += `&stopLat=${sLat}&stopLon=${sLon}`;
@@ -334,7 +330,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
};
loadShapes().catch((err) => console.error("Failed to load shape", err));
- }, [isOpen, selectedBus, regionConfig.shapeEndpoint]);
+ }, [isOpen, selectedBus]);
if (busesWithPosition.length === 0) {
return null; // Don't render if no buses with GPS coordinates
@@ -362,7 +358,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
}}
style={{ width: "100%", height: "50vh" }}
mapStyle={styleSpec}
- attributionControl={{compact: false, customAttribution: "Concello de Vigo & Viguesa de Transportes SL"}}
+ attributionControl={{ compact: false, customAttribution: "Concello de Vigo & Viguesa de Transportes SL" }}
ref={mapRef}
interactive={true}
onMove={(e) => {
@@ -422,7 +418,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
id="prev-route-shape-inner"
type="line"
paint={{
- "line-color": getLineColor(region, selectedBus.line)
+ "line-color": getLineColour(selectedBus.line)
.background,
"line-width": 4,
"line-dasharray": [2, 2],
@@ -455,7 +451,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
id="route-shape-inner"
type="line"
paint={{
- "line-color": getLineColor(region, selectedBus.line)
+ "line-color": getLineColour(selectedBus.line)
.background,
"line-width": 3,
"line-opacity": 0.7,
@@ -534,7 +530,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
>
<path
d="M12 2 L22 22 L12 17 L2 22 Z"
- fill={getLineColor(region, selectedBus.line).background}
+ fill={getLineColour(selectedBus.line).background}
stroke="#000"
strokeWidth="2"
strokeLinejoin="round"
diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx
index afad1c3..b00ca1c 100644
--- a/src/frontend/app/components/StopMapSheet.tsx
+++ b/src/frontend/app/components/StopMapSheet.tsx
@@ -2,8 +2,8 @@ import maplibregl from "maplibre-gl";
import React, { useEffect, useMemo, useRef, useState } from "react";
import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
import { useApp } from "~/AppContext";
-import { getRegionConfig, type RegionId } from "~/config/RegionConfig";
-import { getLineColor } from "~/data/LineColors";
+import { REGION_DATA } from "~/config/RegionConfig";
+import { getLineColour } from "~/data/LineColors";
import type { Stop } from "~/data/StopDataProvider";
import { loadStyle } from "~/maps/styleloader";
import "./StopMapSheet.css";
@@ -28,13 +28,11 @@ export interface ConsolidatedCirculationForMap {
interface StopMapProps {
stop: Stop;
circulations: ConsolidatedCirculationForMap[];
- region: RegionId;
}
export const StopMap: React.FC<StopMapProps> = ({
stop,
- circulations,
- region,
+ circulations
}) => {
const { theme } = useApp();
const [styleSpec, setStyleSpec] = useState<any | null>(null);
@@ -70,18 +68,16 @@ export const StopMap: React.FC<StopMapProps> = ({
const [showAttribution, setShowAttribution] = useState(false);
const [shapes, setShapes] = useState<Record<string, any>>({});
- const regionConfig = getRegionConfig(region);
-
useEffect(() => {
circulations.forEach((c) => {
if (
c.schedule?.shapeId &&
c.currentPosition?.shapeIndex !== undefined &&
- regionConfig.shapeEndpoint
+ REGION_DATA.shapeEndpoint
) {
const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`;
if (!shapes[key]) {
- let url = `${regionConfig.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`;
+ let url = `${REGION_DATA.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`;
if (c.stopShapeIndex !== undefined) {
url += `&stopShapeIndex=${c.stopShapeIndex}`;
} else {
@@ -102,7 +98,7 @@ export const StopMap: React.FC<StopMapProps> = ({
}
}
});
- }, [circulations, regionConfig.shapeEndpoint, shapes]);
+ }, [circulations, shapes]);
type Pt = { lat: number; lon: number };
const haversineKm = (a: Pt, b: Pt) => {
@@ -195,7 +191,7 @@ export const StopMap: React.FC<StopMapProps> = ({
accuracy: pos.coords.accuracy,
});
},
- () => {},
+ () => { },
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 }
);
geoWatchId.current = navigator.geolocation.watchPosition(
@@ -206,15 +202,15 @@ export const StopMap: React.FC<StopMapProps> = ({
accuracy: pos.coords.accuracy,
});
},
- () => {},
+ () => { },
{ enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 }
);
- } catch {}
+ } catch { }
return () => {
if (geoWatchId.current != null && "geolocation" in navigator) {
try {
navigator.geolocation.clearWatch(geoWatchId.current);
- } catch {}
+ } catch { }
}
};
}, []);
@@ -278,7 +274,7 @@ export const StopMap: React.FC<StopMapProps> = ({
maxZoom: 17,
} as any);
}
- } catch {}
+ } catch { }
};
const handleCenter = () => {
@@ -318,7 +314,7 @@ export const StopMap: React.FC<StopMapProps> = ({
maxZoom: 17,
} as any);
}
- } catch {}
+ } catch { }
};
return (
@@ -347,7 +343,7 @@ export const StopMap: React.FC<StopMapProps> = ({
const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`;
const shapeData = shapes[key];
if (!shapeData) return null;
- const lineColor = getLineColor(region, c.line);
+ const lineColor = getLineColour(c.line);
return (
<Source
@@ -439,9 +435,9 @@ export const StopMap: React.FC<StopMapProps> = ({
const pts = busPositions.map((c) =>
c.currentPosition
? map.project([
- c.currentPosition.longitude,
- c.currentPosition.latitude,
- ])
+ c.currentPosition.longitude,
+ c.currentPosition.latitude,
+ ])
: null
);
for (let i = 0; i < pts.length; i++) {
@@ -462,7 +458,7 @@ export const StopMap: React.FC<StopMapProps> = ({
return busPositions.map((c, idx) => {
const p = c.currentPosition!;
- const lineColor = getLineColor(region, c.line);
+ const lineColor = getLineColour(c.line);
const showLabel = zoom >= 13;
const labelGap = gaps[idx] ?? baseGap;
return (
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
index 6d2abf0..77bb5f1 100644
--- a/src/frontend/app/components/StopSheet.tsx
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -3,9 +3,8 @@ import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sheet } from "react-modal-sheet";
import { Link } from "react-router";
+import { REGION_DATA } from "~/config/RegionConfig";
import type { Stop } from "~/data/StopDataProvider";
-import { useApp } from "../AppContext";
-import { type RegionId, getRegionConfig } from "../config/RegionConfig";
import { type ConsolidatedCirculation } from "../routes/stops-$id";
import { ErrorDisplay } from "./ErrorDisplay";
import LineIcon from "./LineIcon";
@@ -27,12 +26,10 @@ interface ErrorInfo {
}
const loadConsolidatedData = async (
- region: RegionId,
stopId: number
): Promise<ConsolidatedCirculation[]> => {
- const regionConfig = getRegionConfig(region);
const resp = await fetch(
- `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
{
headers: {
Accept: "application/json",
@@ -53,8 +50,6 @@ export const StopSheet: React.FC<StopSheetProps> = ({
stop,
}) => {
const { t } = useTranslation();
- const { region } = useApp();
- const regionConfig = getRegionConfig(region);
const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo | null>(null);
@@ -87,7 +82,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
setError(null);
setData(null);
- const stopData = await loadConsolidatedData(region, stop.stopId);
+ const stopData = await loadConsolidatedData(stop.stopId);
setData(stopData);
setLastUpdated(new Date());
} catch (err) {
@@ -102,15 +97,15 @@ export const StopSheet: React.FC<StopSheetProps> = ({
if (isOpen && stop.stopId) {
loadData();
}
- }, [isOpen, stop.stopId, region]);
+ }, [isOpen, stop.stopId]);
// Show only the next 4 arrivals
const sortedData = data
? [...data].sort(
- (a, b) =>
- (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
- (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
- )
+ (a, b) =>
+ (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
+ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
+ )
: [];
const limitedEstimates = sortedData.slice(0, 4);
@@ -130,7 +125,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
>
{stop.lines.map((line) => (
<div key={line} className="stop-sheet-line-icon">
- <LineIcon line={line} region={region} mode="rounded" />
+ <LineIcon line={line} mode="rounded" />
</div>
))}
</div>
@@ -166,7 +161,6 @@ export const StopSheet: React.FC<StopSheetProps> = ({
<ConsolidatedCirculationCard
key={idx}
estimate={estimate}
- regionConfig={regionConfig}
readonly
/>
))}
@@ -179,39 +173,39 @@ export const StopSheet: React.FC<StopSheetProps> = ({
</Sheet.Content>
<div className="stop-sheet-footer">
- {lastUpdated && (
- <div className="stop-sheet-timestamp">
- {t("estimates.last_updated", "Actualizado a las")}{" "}
- {lastUpdated.toLocaleTimeString(undefined, {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- })}
- </div>
- )}
+ {lastUpdated && (
+ <div className="stop-sheet-timestamp">
+ {t("estimates.last_updated", "Actualizado a las")}{" "}
+ {lastUpdated.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ </div>
+ )}
- <div className="stop-sheet-actions">
- <button
- className="stop-sheet-reload"
- onClick={loadData}
- disabled={loading}
- title={t("estimates.reload", "Recargar estimaciones")}
- >
- <RefreshCw
- className={`reload-icon ${loading ? "spinning" : ""}`}
- />
- {t("estimates.reload", "Recargar")}
- </button>
+ <div className="stop-sheet-actions">
+ <button
+ className="stop-sheet-reload"
+ onClick={loadData}
+ disabled={loading}
+ title={t("estimates.reload", "Recargar estimaciones")}
+ >
+ <RefreshCw
+ className={`reload-icon ${loading ? "spinning" : ""}`}
+ />
+ {t("estimates.reload", "Recargar")}
+ </button>
- <Link
- to={`/stops/${stop.stopId}`}
- className="stop-sheet-view-all"
- onClick={onClose}
- >
- {t("map.view_all_estimates", "Ver todas las estimaciones")}
- </Link>
- </div>
+ <Link
+ to={`/stops/${stop.stopId}`}
+ className="stop-sheet-view-all"
+ onClick={onClose}
+ >
+ {t("map.view_all_estimates", "Ver todas las estimaciones")}
+ </Link>
</div>
+ </div>
</Sheet.Container>
<Sheet.Backdrop onTap={onClose} />
</Sheet>
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css
index 3dc33ea..e61ac25 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css
@@ -1,4 +1,4 @@
-@import "tailwindcss";
+@import '../../tailwind.css';
.consolidated-circulation-card {
all: unset;
@@ -133,19 +133,19 @@
}
.meta-chip.delay-ok {
- @apply bg-green-400/30 dark:bg-green-600/30 border-green-500 dark:border-green-700 text-green-800 dark:text-green-200;
+ @apply bg-green-600/80 dark:bg-green-600/30 border-green-500 dark:border-green-700 text-white dark:text-green-200;
}
.meta-chip.delay-warn {
- @apply bg-yellow-400/30 dark:bg-yellow-600/30 border-yellow-500 dark:border-yellow-700 text-yellow-800 dark:text-yellow-200;
+ @apply bg-amber-600/80 dark:bg-yellow-600/30 border-yellow-500 dark:border-yellow-700 text-white dark:text-yellow-200;
}
.meta-chip.delay-critical {
- @apply bg-red-400/30 dark:bg-red-600/30 border-red-500 dark:border-red-700 text-white;
+ @apply bg-red-400/80 dark:bg-red-600/30 border-red-500 dark:border-red-700 text-white;
}
.meta-chip.delay-early {
- @apply bg-blue-400/30 dark:bg-blue-600/30 border-blue-500 dark:border-blue-700 text-blue-800 dark:text-blue-200;
+ @apply bg-blue-400/80 dark:bg-blue-600/30 border-blue-500 dark:border-blue-700 text-white dark:text-blue-200;
}
/* GPS Indicator */
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
index 0b97c11..6f92644 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
-import { type RegionConfig } from "~/config/RegionConfig";
import LineIcon from "~components/LineIcon";
import { type ConsolidatedCirculation } from "~routes/stops-$id";
@@ -8,7 +7,6 @@ import "./ConsolidatedCirculationCard.css";
interface ConsolidatedCirculationCardProps {
estimate: ConsolidatedCirculation;
- regionConfig: RegionConfig;
onMapClick?: () => void;
readonly?: boolean;
}
@@ -72,7 +70,7 @@ const parseServiceId = (serviceId: string): string => {
export const ConsolidatedCirculationCard: React.FC<
ConsolidatedCirculationCardProps
-> = ({ estimate, regionConfig, onMapClick, readonly }) => {
+> = ({ estimate, onMapClick, readonly }) => {
const { t } = useTranslation();
const formatDistance = (meters: number) => {
@@ -171,30 +169,28 @@ export const ConsolidatedCirculationCard: React.FC<
const interactiveProps = readonly
? {}
: {
- onClick: onMapClick,
- type: "button" as const,
- disabled: !hasGpsPosition,
- };
+ onClick: onMapClick,
+ type: "button" as const,
+ disabled: !hasGpsPosition,
+ };
return (
<Tag
- className={`consolidated-circulation-card ${
- readonly
- ? !hasGpsPosition
- ? "no-gps"
- : ""
- : hasGpsPosition
+ className={`consolidated-circulation-card ${readonly
+ ? !hasGpsPosition
+ ? "no-gps"
+ : ""
+ : hasGpsPosition
? "has-gps"
: "no-gps"
- }`}
+ }`}
{...interactiveProps}
- aria-label={`${hasGpsPosition ? "View" : "No GPS data for"} ${
- estimate.line
- } to ${estimate.route}${hasGpsPosition ? " on map" : ""}`}
+ aria-label={`${hasGpsPosition ? "View" : "No GPS data for"} ${estimate.line
+ } to ${estimate.route}${hasGpsPosition ? " on map" : ""}`}
>
<div className="card-row main">
<div className="line-info">
- <LineIcon line={estimate.line} region={regionConfig.id} mode="pill" />
+ <LineIcon line={estimate.line} mode="pill" />
</div>
<div className="route-info">
<strong>{estimate.route}</strong>
@@ -202,9 +198,8 @@ export const ConsolidatedCirculationCard: React.FC<
{hasGpsPosition && (
<div className="gps-indicator" title="Live GPS tracking">
<span
- className={`gps-pulse ${
- estimate.isPreviousTrip ? "previous-trip" : ""
- }`}
+ className={`gps-pulse ${estimate.isPreviousTrip ? "previous-trip" : ""
+ }`}
/>
</div>
)}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
index 4c2916a..547fdf7 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
-import { type RegionConfig } from "~/config/RegionConfig";
import { type ConsolidatedCirculation } from "~routes/stops-$id";
import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard";
@@ -8,14 +7,12 @@ import "./ConsolidatedCirculationList.css";
interface RegularTableProps {
data: ConsolidatedCirculation[];
dataDate: Date | null;
- regionConfig: RegionConfig;
onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void;
}
export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
data,
dataDate,
- regionConfig,
onCirculationClick,
}) => {
const { t } = useTranslation();
@@ -44,7 +41,6 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
<ConsolidatedCirculationCard
key={idx}
estimate={estimate}
- regionConfig={regionConfig}
onMapClick={() => onCirculationClick?.(estimate, idx)}
/>
))}
diff --git a/src/frontend/app/components/layout/Header.tsx b/src/frontend/app/components/layout/Header.tsx
index 2bdd764..b8184f1 100644
--- a/src/frontend/app/components/layout/Header.tsx
+++ b/src/frontend/app/components/layout/Header.tsx
@@ -1,5 +1,4 @@
import { Menu } from "lucide-react";
-import React from "react";
import "./Header.css";
interface HeaderProps {
diff --git a/src/frontend/app/components/ui/Button.css b/src/frontend/app/components/ui/Button.css
deleted file mode 100644
index bf02a7c..0000000
--- a/src/frontend/app/components/ui/Button.css
+++ /dev/null
@@ -1,39 +0,0 @@
-.ui-button {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1.5rem;
- border-radius: 0.5rem;
- font-size: 0.9rem;
- font-weight: 500;
- cursor: pointer;
- transition: background-color 0.2s ease, transform 0.1s ease;
- border: none;
-}
-
-.ui-button:active {
- transform: translateY(1px);
-}
-
-.ui-button--primary {
- background: var(--button-background-color);
- color: white;
-}
-
-.ui-button--primary:hover {
- background: var(--button-hover-background-color);
-}
-
-.ui-button--secondary {
- background: var(--border-color);
- color: var(--text-color);
-}
-
-.ui-button--secondary:hover {
- background: #e0e0e0;
-}
-
-.ui-button__icon {
- display: flex;
- align-items: center;
-}
diff --git a/src/frontend/app/components/ui/Button.tsx b/src/frontend/app/components/ui/Button.tsx
deleted file mode 100644
index 18a15b2..0000000
--- a/src/frontend/app/components/ui/Button.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from "react";
-import "./Button.css";
-
-interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
- variant?: "primary" | "secondary" | "outline";
- icon?: React.ReactNode;
-}
-
-export const Button: React.FC<ButtonProps> = ({
- children,
- variant = "primary",
- icon,
- className = "",
- ...props
-}) => {
- return (
- <button
- className={`ui-button ui-button--${variant} ${className}`}
- {...props}
- >
- {icon && <span className="ui-button__icon">{icon}</span>}
- {children}
- </button>
- );
-};
diff --git a/src/frontend/app/components/ui/PageContainer.css b/src/frontend/app/components/ui/PageContainer.css
deleted file mode 100644
index 8a86035..0000000
--- a/src/frontend/app/components/ui/PageContainer.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.page-container {
- max-width: 100%;
- padding: 0 16px;
- background-color: var(--background-color);
- color: var(--text-color);
-}
-
-@media (min-width: 768px) {
- .page-container {
- width: 90%;
- max-width: 768px;
- margin: 0 auto;
- }
-}
-
-@media (min-width: 1024px) {
- .page-container {
- max-width: 1024px;
- }
-}
diff --git a/src/frontend/app/components/ui/PageContainer.tsx b/src/frontend/app/components/ui/PageContainer.tsx
deleted file mode 100644
index 4c9684a..0000000
--- a/src/frontend/app/components/ui/PageContainer.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from "react";
-import "./PageContainer.css";
-
-interface PageContainerProps {
- children: React.ReactNode;
- className?: string;
-}
-
-export const PageContainer: React.FC<PageContainerProps> = ({
- children,
- className = "",
-}) => {
- return <div className={`page-container ${className}`}>{children}</div>;
-};
diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts
index a6ffdf8..4677509 100644
--- a/src/frontend/app/config/RegionConfig.ts
+++ b/src/frontend/app/config/RegionConfig.ts
@@ -1,53 +1,22 @@
-export type RegionId = "vigo";
+import type { LngLatLike } from "maplibre-gl";
-export interface RegionConfig {
- id: RegionId;
- name: string;
- stopsEndpoint: string;
- estimatesEndpoint: string;
- consolidatedCirculationsEndpoint: string | null;
- timetableEndpoint: string | null;
- shapeEndpoint: string | null;
- defaultCenter: [number, number]; // [lat, lng]
- bounds?: {
- sw: [number, number];
- ne: [number, number];
- };
- textColour?: string;
- 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",
- consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
- timetableEndpoint: "/api/vigo/GetStopTimetable",
- shapeEndpoint: "/api/vigo/GetShape",
- defaultCenter: [42.229188855975046, -8.72246955783102],
- bounds: {
- sw: [-8.951059, 42.098923],
- ne: [-8.447748, 42.3496],
- },
- textColour: "#e72b37",
- defaultZoom: 14,
- showMeters: true,
+export const REGION_DATA = {
+ id: "vigo",
+ name: "Vigo",
+ stopsEndpoint: "/stops/vigo.json",
+ estimatesEndpoint: "/api/vigo/GetStopEstimates",
+ consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
+ timetableEndpoint: "/api/vigo/GetStopTimetable",
+ shapeEndpoint: "/api/vigo/GetShape",
+ defaultCenter: [
+ 42.229188855975046,
+ -8.72246955783102
+ ] as LngLatLike,
+ bounds: {
+ sw: [-8.951059, 42.098923] as LngLatLike,
+ ne: [-8.447748, 42.3496] as LngLatLike,
},
+ textColour: "#e72b37",
+ defaultZoom: 14,
+ showMeters: true,
};
-
-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";
-}
diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx
index b47b67f..af13bb7 100644
--- a/src/frontend/app/contexts/MapContext.tsx
+++ b/src/frontend/app/contexts/MapContext.tsx
@@ -1,13 +1,12 @@
import { type LngLatLike } from "maplibre-gl";
import {
- createContext,
- useContext,
- useEffect,
- useState,
- type ReactNode,
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
} from "react";
-import { getRegionConfig } from "../config/RegionConfig";
-import { useSettings } from "./SettingsContext";
+import { REGION_DATA } from "~/config/RegionConfig";
interface MapState {
center: LngLatLike;
@@ -28,9 +27,6 @@ interface MapContextProps {
const MapContext = createContext<MapContextProps | undefined>(undefined);
export const MapProvider = ({ children }: { children: ReactNode }) => {
- const { region } = useSettings();
- const [prevRegion, setPrevRegion] = useState(region);
-
const [mapState, setMapState] = useState<MapState>(() => {
const savedMapState = localStorage.getItem("mapState");
if (savedMapState) {
@@ -39,10 +35,9 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
// Validate that the saved center is valid if needed, or just trust it.
// We might want to ensure we have a fallback if the region changed while the app was closed?
// But for now, let's stick to the existing logic.
- const regionConfig = getRegionConfig(region);
return {
- center: parsed.center || regionConfig.defaultCenter,
- zoom: parsed.zoom || regionConfig.defaultZoom,
+ center: parsed.center || REGION_DATA.defaultCenter,
+ zoom: parsed.zoom || REGION_DATA.defaultZoom,
userLocation: parsed.userLocation || null,
hasLocationPermission: parsed.hasLocationPermission || false,
};
@@ -50,10 +45,9 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
console.error("Error parsing saved map state", e);
}
}
- const regionConfig = getRegionConfig(region);
return {
- center: regionConfig.defaultCenter,
- zoom: regionConfig.defaultZoom,
+ center: REGION_DATA.defaultCenter,
+ zoom: REGION_DATA.defaultZoom,
userLocation: null,
hasLocationPermission: false,
};
@@ -99,15 +93,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
});
};
- // Sync map state when region changes
- useEffect(() => {
- if (region !== prevRegion) {
- const regionConfig = getRegionConfig(region);
- updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom);
- setPrevRegion(region);
- }
- }, [region, prevRegion]);
-
// Try to get user location on load if permission was granted
useEffect(() => {
if (mapState.hasLocationPermission && !mapState.userLocation) {
diff --git a/src/frontend/app/contexts/SettingsContext.tsx b/src/frontend/app/contexts/SettingsContext.tsx
index a273008..5f6ff46 100644
--- a/src/frontend/app/contexts/SettingsContext.tsx
+++ b/src/frontend/app/contexts/SettingsContext.tsx
@@ -1,16 +1,11 @@
import {
- createContext,
- useContext,
- useEffect,
- useState,
- type ReactNode,
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
} from "react";
import { APP_CONFIG } from "../config/AppConfig";
-import {
- DEFAULT_REGION,
- isValidRegion,
- type RegionId
-} from "../config/RegionConfig";
export type Theme = "light" | "dark" | "system";
export type TableStyle = "regular" | "grouped" | "experimental_consolidated";
@@ -21,15 +16,8 @@ interface SettingsContextProps {
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
toggleTheme: () => void;
- tableStyle: TableStyle;
- setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>;
- toggleTableStyle: () => void;
-
mapPositionMode: MapPositionMode;
setMapPositionMode: (mode: MapPositionMode) => void;
-
- region: RegionId;
- setRegion: (region: RegionId) => void;
resolvedTheme: "light" | "dark";
}
@@ -151,38 +139,14 @@ export const SettingsProvider = ({ 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);
- };
-
- useEffect(() => {
- localStorage.setItem("region", region);
- }, [region]);
- //#endregion
-
return (
<SettingsContext.Provider
value={{
theme,
setTheme,
toggleTheme,
- tableStyle,
- setTableStyle,
- toggleTableStyle,
mapPositionMode,
setMapPositionMode,
- region,
- setRegion,
resolvedTheme,
}}
>
diff --git a/src/frontend/app/data/LineColors.ts b/src/frontend/app/data/LineColors.ts
index fba150d..4e5fe8f 100644
--- a/src/frontend/app/data/LineColors.ts
+++ b/src/frontend/app/data/LineColors.ts
@@ -1,4 +1,3 @@
-import type { RegionId } from "../config/RegionConfig";
interface LineColorInfo {
background: string;
@@ -58,15 +57,11 @@ const defaultLineColor: LineColorInfo = {
text: "#ffffff",
};
-export function getLineColor(region: RegionId, line: string): LineColorInfo {
+export function getLineColour(line: string): LineColorInfo {
let formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
formattedLine = formattedLine.toLowerCase().trim();
- if (region === "vigo") {
- return (
- vigoLineColors[formattedLine.toLowerCase().trim()] ?? defaultLineColor
- );
- }
-
- return defaultLineColor;
+ return (
+ vigoLineColors[formattedLine.toLowerCase().trim()] ?? defaultLineColor
+ );
}
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 2f13e43..abe7123 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,4 +1,4 @@
-import { type RegionId, getRegionConfig } from "../config/RegionConfig";
+import { REGION_DATA } from "~/config/RegionConfig";
export interface CachedStopList {
timestamp: number;
@@ -32,51 +32,49 @@ const stopsMapByRegion: Record<string, Record<number, Stop>> = {};
const customNamesByRegion: Record<string, Record<number, string>> = {};
// 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);
+async function initStops() {
+ if (!cachedStopsByRegion[REGION_DATA.id]) {
+ const response = await fetch(REGION_DATA.stopsEndpoint);
const stops = (await response.json()) as Stop[];
// build array and map
- stopsMapByRegion[region] = {};
- cachedStopsByRegion[region] = stops.map((stop) => {
+ stopsMapByRegion[REGION_DATA.id] = {};
+ cachedStopsByRegion[REGION_DATA.id] = stops.map((stop) => {
const entry = { ...stop, favourite: false } as Stop;
- stopsMapByRegion[region][stop.stopId] = entry;
+ stopsMapByRegion[REGION_DATA.id][stop.stopId] = entry;
return entry;
});
// load custom names
- const rawCustom = localStorage.getItem(`customStopNames_${region}`);
+ const rawCustom = localStorage.getItem(`customStopNames_${REGION_DATA.id}`);
if (rawCustom) {
- customNamesByRegion[region] = JSON.parse(rawCustom) as Record<
+ customNamesByRegion[REGION_DATA.id] = JSON.parse(rawCustom) as Record<
number,
string
>;
} else {
- customNamesByRegion[region] = {};
+ customNamesByRegion[REGION_DATA.id] = {};
}
}
}
-async function getStops(region: RegionId): Promise<Stop[]> {
- await initStops(region);
+async function getStops(): Promise<Stop[]> {
+ await initStops();
// update favourites
- const rawFav = localStorage.getItem(`favouriteStops_${region}`);
+ const rawFav = localStorage.getItem("favouriteStops_vigo");
const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
- cachedStopsByRegion[region]!.forEach(
+ cachedStopsByRegion["vigo"]!.forEach(
(stop) => (stop.favourite = favouriteStops.includes(stop.stopId))
);
- return cachedStopsByRegion[region]!;
+ return cachedStopsByRegion["vigo"]!;
}
// New: get single stop by id
async function getStopById(
- region: RegionId,
stopId: number
): Promise<Stop | undefined> {
- await initStops(region);
- const stop = stopsMapByRegion[region]?.[stopId];
+ await initStops();
+ const stop = stopsMapByRegion[REGION_DATA.id]?.[stopId];
if (stop) {
- const rawFav = localStorage.getItem(`favouriteStops_${region}`);
+ const rawFav = localStorage.getItem(`favouriteStops_${REGION_DATA.id}`);
const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
stop.favourite = favouriteStops.includes(stopId);
}
@@ -84,42 +82,42 @@ async function getStopById(
}
// Updated display name to include custom names
-function getDisplayName(region: RegionId, stop: Stop): string {
- const customNames = customNamesByRegion[region] || {};
+function getDisplayName(stop: Stop): string {
+ const customNames = customNamesByRegion[REGION_DATA.id] || {};
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(region: RegionId, stopId: number, label: string) {
- if (!customNamesByRegion[region]) {
- customNamesByRegion[region] = {};
+function setCustomName(stopId: number, label: string) {
+ if (!customNamesByRegion[REGION_DATA.id]) {
+ customNamesByRegion[REGION_DATA.id] = {};
}
- customNamesByRegion[region][stopId] = label;
+ customNamesByRegion[REGION_DATA.id][stopId] = label;
localStorage.setItem(
- `customStopNames_${region}`,
- JSON.stringify(customNamesByRegion[region])
+ `customStopNames_${REGION_DATA.id}`,
+ JSON.stringify(customNamesByRegion[REGION_DATA.id])
);
}
-function removeCustomName(region: RegionId, stopId: number) {
- if (customNamesByRegion[region]) {
- delete customNamesByRegion[region][stopId];
+function removeCustomName(stopId: number) {
+ if (customNamesByRegion[REGION_DATA.id]?.[stopId]) {
+ delete customNamesByRegion[REGION_DATA.id][stopId];
localStorage.setItem(
- `customStopNames_${region}`,
- JSON.stringify(customNamesByRegion[region])
+ `customStopNames_${REGION_DATA.id}`,
+ JSON.stringify(customNamesByRegion[REGION_DATA.id])
);
}
}
// New: get custom label for a stop
-function getCustomName(region: RegionId, stopId: number): string | undefined {
- return customNamesByRegion[region]?.[stopId];
+function getCustomName(stopId: number): string | undefined {
+ return customNamesByRegion[REGION_DATA.id]?.[stopId];
}
-function addFavourite(region: RegionId, stopId: number) {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
+function addFavourite(stopId: number) {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
let favouriteStops: number[] = [];
if (rawFavouriteStops) {
favouriteStops = JSON.parse(rawFavouriteStops) as number[];
@@ -128,14 +126,14 @@ function addFavourite(region: RegionId, stopId: number) {
if (!favouriteStops.includes(stopId)) {
favouriteStops.push(stopId);
localStorage.setItem(
- `favouriteStops_${region}`,
+ `favouriteStops_vigo`,
JSON.stringify(favouriteStops)
);
}
}
-function removeFavourite(region: RegionId, stopId: number) {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
+function removeFavourite(stopId: number) {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
let favouriteStops: number[] = [];
if (rawFavouriteStops) {
favouriteStops = JSON.parse(rawFavouriteStops) as number[];
@@ -143,13 +141,13 @@ function removeFavourite(region: RegionId, stopId: number) {
const newFavouriteStops = favouriteStops.filter((id) => id !== stopId);
localStorage.setItem(
- `favouriteStops_${region}`,
+ `favouriteStops_vigo`,
JSON.stringify(newFavouriteStops)
);
}
-function isFavourite(region: RegionId, stopId: number): boolean {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
+function isFavourite(stopId: number): boolean {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
if (rawFavouriteStops) {
const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
return favouriteStops.includes(stopId);
@@ -159,8 +157,8 @@ function isFavourite(region: RegionId, stopId: number): boolean {
const RECENT_STOPS_LIMIT = 10;
-function pushRecent(region: RegionId, stopId: number) {
- const rawRecentStops = localStorage.getItem(`recentStops_${region}`);
+function pushRecent(stopId: number) {
+ const rawRecentStops = localStorage.getItem(`recentStops_vigo`);
let recentStops: Set<number> = new Set();
if (rawRecentStops) {
recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
@@ -174,21 +172,21 @@ function pushRecent(region: RegionId, stopId: number) {
}
localStorage.setItem(
- `recentStops_${region}`,
+ `recentStops_vigo`,
JSON.stringify(Array.from(recentStops))
);
}
-function getRecent(region: RegionId): number[] {
- const rawRecentStops = localStorage.getItem(`recentStops_${region}`);
+function getRecent(): number[] {
+ const rawRecentStops = localStorage.getItem(`recentStops_vigo`);
if (rawRecentStops) {
return JSON.parse(rawRecentStops) as number[];
}
return [];
}
-function getFavouriteIds(region: RegionId): number[] {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`);
+function getFavouriteIds(): number[] {
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
if (rawFavouriteStops) {
return JSON.parse(rawFavouriteStops) as number[];
}
@@ -196,9 +194,8 @@ function getFavouriteIds(region: RegionId): number[] {
}
// New function to load stops from network
-async function loadStopsFromNetwork(region: RegionId): Promise<Stop[]> {
- const regionConfig = getRegionConfig(region);
- const response = await fetch(regionConfig.stopsEndpoint);
+async function loadStopsFromNetwork(): Promise<Stop[]> {
+ const response = await fetch(REGION_DATA.stopsEndpoint);
const stops = (await response.json()) as Stop[];
return stops.map((stop) => ({ ...stop, favourite: false }) as Stop);
}
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 25ab97f..e09ebb2 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -12,34 +12,10 @@
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
- "table_style": "Table style:",
- "table_style_regular": "Show in order",
- "table_style_grouped": "Group by line",
- "table_style_experimental_consolidated": "(EXPERIMENTAL) Consolidated data",
"map_position_mode": "Map position:",
"map_position_gps": "GPS position",
"map_position_last": "Where I left it",
- "language": "Language",
- "app_updates": "App updates",
- "check_updates": "Check for updates",
- "checking_updates": "Checking...",
- "clear_cache": "Clear cache",
- "sw_not_supported": "Service Workers are not supported in this browser",
- "update_available": "New version available! A notification will appear to update.",
- "up_to_date": "You already have the latest version.",
- "update_error": "Error checking for updates. Try reloading the page.",
- "clear_cache_confirm": "Are you sure you want to clear the cache? This will remove all locally stored data.",
- "cache_cleared": "Cache cleared. The page will reload to apply changes.",
- "cache_error": "Error clearing cache.",
- "reset_pwa": "Reset PWA (Nuclear)",
- "reset_pwa_confirm": "Are you sure? This will delete ALL app data and restart it completely. Use only if there are serious cache issues.",
- "reset_pwa_error": "Error resetting PWA.",
- "update_help": "If you're having issues with the app or don't see the latest features, use these buttons to force an update or clear stored data.",
- "details_summary": "What does this mean?",
- "details_table": "The timetable can be shown in two ways:",
- "details_regular": "Stops are shown in the order they are visited. Apps like Infobus (Vitrasa) use this style.",
- "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style.",
- "details_experimental_consolidated": "Stops are shown using consolidated data from multiple real-time sources. This feature is experimental and may not be completely accurate. It works only in the Vigo region."
+ "language": "Language"
},
"stoplist": {
"search_placeholder": "Search stop by name or code...",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index d567bb3..34d38f8 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -12,34 +12,10 @@
"theme_light": "Claro",
"theme_dark": "Oscuro",
"theme_system": "Sistema",
- "table_style": "Estilo de tabla:",
- "table_style_regular": "Mostrar por orden",
- "table_style_grouped": "Agrupar por línea",
- "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados",
"map_position_mode": "Posición del mapa:",
"map_position_gps": "Posición GPS",
"map_position_last": "Donde lo dejé",
- "language": "Idioma",
- "app_updates": "Actualizaciones de la aplicación",
- "check_updates": "Comprobar actualizaciones",
- "checking_updates": "Comprobando...",
- "clear_cache": "Limpiar caché",
- "sw_not_supported": "Service Workers no son compatibles en este navegador",
- "update_available": "¡Nueva versión disponible! Aparecerá una notificación para actualizar.",
- "up_to_date": "Ya tienes la versión más reciente.",
- "update_error": "Error al comprobar actualizaciones. Intenta recargar la página.",
- "clear_cache_confirm": "¿Estás seguro de que quieres limpiar la caché? Esto eliminará todos los datos guardados localmente.",
- "cache_cleared": "Caché limpiada. La página se recargará para aplicar los cambios.",
- "cache_error": "Error al limpiar la caché.",
- "reset_pwa": "Reiniciar PWA (Nuclear)",
- "reset_pwa_confirm": "¿Estás seguro? Esto eliminará TODOS los datos de la aplicación y la reiniciará completamente. Úsalo solo si hay problemas graves de caché.",
- "reset_pwa_error": "Error al reiniciar la PWA.",
- "update_help": "Si tienes problemas con la aplicación o no ves las últimas funciones, usa estos botones para forzar una actualización o limpiar los datos guardados.",
- "details_summary": "¿Qué significa esto?",
- "details_table": "La tabla de horarios puede mostrarse de dos formas:",
- "details_regular": "Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.",
- "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.",
- "details_experimental_consolidated": "Las paradas se muestran utilizando datos consolidados de múltiples fuentes en tiempo real. Esta función está en fase experimental y puede no ser completamente precisa. Funciona únicamente en la región de Vigo."
+ "language": "Idioma"
},
"stoplist": {
"search_placeholder": "Buscar parada por nombre o código...",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index aab3140..1b98730 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -12,34 +12,10 @@
"theme_light": "Claro",
"theme_dark": "Escuro",
"theme_system": "Sistema",
- "table_style": "Estilo de táboa:",
- "table_style_regular": "Mostrar por orde",
- "table_style_grouped": "Agrupar por liña",
- "table_style_experimental_consolidated": "(EXPERIMENTAL) Datos consolidados",
"map_position_mode": "Posición do mapa:",
"map_position_gps": "Posición GPS",
"map_position_last": "Onde o deixei",
- "language": "Idioma",
- "app_updates": "Actualizacións da aplicación",
- "check_updates": "Comprobar actualizacións",
- "checking_updates": "Comprobando...",
- "clear_cache": "Limpar caché",
- "sw_not_supported": "Os Service Workers non son compatibles neste navegador",
- "update_available": "Nova versión dispoñible! Aparecerá unha notificación para actualizar.",
- "up_to_date": "Xa tes a versión máis recente.",
- "update_error": "Erro ao comprobar actualizacións. Tenta recargar a páxina.",
- "clear_cache_confirm": "Estás seguro de que queres limpar a caché? Isto eliminará todos os datos gardados localmente.",
- "cache_cleared": "Caché limpa. A páxina recargarase para aplicar os cambios.",
- "cache_error": "Erro ao limpar a caché.",
- "reset_pwa": "Reiniciar PWA (Nuclear)",
- "reset_pwa_confirm": "Estás seguro? Isto eliminará TODOS os datos da aplicación e a reiniciará completamente. Úsao só se hai problemas graves de caché.",
- "reset_pwa_error": "Erro ao reiniciar a PWA.",
- "update_help": "Se tes problemas coa aplicación ou non ves as últimas funcións, usa estes botóns para forzar unha actualización ou limpar os datos gardados.",
- "details_summary": "Que significa isto?",
- "details_table": "A táboa de horarios pode mostrarse de dúas formas:",
- "details_regular": "As paradas móstranse na orde na que se visitan. Aplicacións como Infobus (Vitrasa) usan este estilo.",
- "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo.",
- "details_experimental_consolidated": "As paradas móstranse utilizando datos consolidados de múltiples fontes en tempo real. Esta función está en fase experimental e pode non ser completamente precisa. Funciona unicamente na rexión de Vigo."
+ "language": "Idioma"
},
"stoplist": {
"search_placeholder": "Buscar parada por nome ou código...",
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 60671cd..4ade748 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -5,8 +5,6 @@ export default [
route("/map", "routes/map.tsx"),
route("/stops", "routes/stops.tsx"),
route("/stops/:id", "routes/stops-$id.tsx"),
- route("/estimates/:id", "routes/estimates-$id.tsx"),
- route("/timetable/:id", "routes/timetable-$id.tsx"),
route("/settings", "routes/settings.tsx"),
route("/about", "routes/about.tsx"),
route("/favourites", "routes/favourites.tsx"),
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
deleted file mode 100644
index 0658156..0000000
--- a/src/frontend/app/routes/estimates-$id.css
+++ /dev/null
@@ -1,270 +0,0 @@
-.table-responsive {
- overflow-x: auto;
- margin-bottom: 1.5rem;
-}
-
-.table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.table caption {
- margin-bottom: 0.5rem;
- font-weight: 500;
-}
-
-.table th,
-.table td {
- padding: 0.75rem;
- text-align: left;
- border-bottom: 1px solid #eee;
-}
-
-.table th {
- border-bottom: 2px solid #ddd;
-}
-
-.table tfoot td {
- text-align: center;
-}
-
-/* Estimates page specific styles */
-.estimates-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 1rem;
- gap: 1rem;
-}
-
-.manual-refresh-button {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0.75rem;
- background: var(--primary-color);
- border: none;
- border-radius: 0.375rem;
- font-size: 0.875rem;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
- min-width: max-content;
-}
-
-.manual-refresh-button:hover:not(:disabled) {
- background: var(--primary-color-hover);
- transform: translateY(-1px);
-}
-
-.manual-refresh-button:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- transform: none;
-}
-
-.refresh-icon {
- width: 1.5rem;
- height: 1.5rem;
- transition: transform 0.2s ease;
-}
-
-.refresh-icon.spinning {
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (max-width: 640px) {
- .estimates-header {
- flex-direction: column;
- align-items: flex-start;
- gap: 0.75rem;
- }
-
- .manual-refresh-button {
- align-self: flex-end;
- padding: 0.4rem 0.6rem;
- font-size: 0.8rem;
- }
-}
-
-.estimates-stop-id {
- font-size: 1rem;
- color: var(--subtitle-color);
- margin-left: 0.5rem;
-}
-
-.estimates-arrival {
- color: #28a745;
- font-weight: 500;
-}
-
-.estimates-delayed {
- color: #dc3545;
-}
-
-.button-group {
- display: flex;
- gap: 1rem;
- margin-bottom: 1.5rem;
- flex-wrap: wrap;
-}
-
-.button {
- padding: 0.75rem 1rem;
- background-color: var(--button-background-color);
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 1rem;
- font-weight: 500;
- cursor: pointer;
- text-align: center;
- text-decoration: none;
- display: inline-block;
-}
-
-.button:hover {
- background-color: var(--button-hover-background-color);
-}
-
-.button:disabled {
- background-color: var(--button-disabled-background-color);
- cursor: not-allowed;
-}
-
-.star-icon {
- margin-right: 0.5rem;
- color: #ccc;
- fill: none;
-}
-
-.star-icon.active {
- color: var(--star-color);
- /* Yellow color for active star */
- fill: var(--star-color);
-}
-
-/* Pencil (edit) icon next to header */
-.edit-icon {
- margin-right: 0.5rem;
- color: #ccc;
- cursor: pointer;
- stroke-width: 2px;
-}
-
-.edit-icon:hover {
- color: var(--star-color);
-}
-
-/* Timetable section styles */
-.timetable-section {
- padding-bottom: 3rem;
-}
-
-/* Timetable cards should be single column */
-.timetable-section .timetable-cards {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.timetable-section .timetable-card {
- padding: 0.875rem;
-}
-
-.timetable-actions {
- margin-top: 1.5rem;
- text-align: center;
-}
-
-.view-all-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- font-weight: 500;
- padding: 0.5rem 1rem;
- border: 1px solid var(--link-color, #007bff);
- border-radius: 6px;
- transition: all 0.2s ease;
-}
-
-.view-all-link:hover {
- background-color: var(--link-color, #007bff);
- color: white;
- text-decoration: none;
-}
-
-.external-icon {
- width: 1rem;
- height: 1rem;
-}
-
-.estimates-lines-container {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
- margin-bottom: 1rem;
-}
-
-.estimates-lines-container.scrollable {
- flex-wrap: nowrap;
- overflow-x: auto;
- max-height: calc(2 * (var(--line-icon-height, 2rem) + 0.5rem));
- align-content: flex-start;
- scrollbar-width: thin;
-}
-
-.estimates-lines-container.scrollable::-webkit-scrollbar {
- height: 6px;
-}
-
-.estimates-lines-container.scrollable::-webkit-scrollbar-thumb {
- background-color: var(--border-color);
- border-radius: 3px;
-}
-
-.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;
-}
-
-[data-theme="dark"] .experimental-notice {
- background-color: #3d3100;
- border-color: #ffc107;
- color: #ffd966;
-}
-
-[data-theme="dark"] .experimental-notice strong {
- color: #ffd966;
-}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
deleted file mode 100644
index afeb3d2..0000000
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ /dev/null
@@ -1,374 +0,0 @@
-import { Edit2, ExternalLink, RefreshCw, Star } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Link, Navigate, useParams } from "react-router";
-import { ErrorDisplay } from "~/components/ErrorDisplay";
-import LineIcon from "~/components/LineIcon";
-import { PullToRefresh } from "~/components/PullToRefresh";
-import {
- type ScheduledTable,
- SchedulesTable,
-} from "~/components/SchedulesTable";
-import {
- EstimatesGroupedSkeleton,
- SchedulesTableSkeleton,
-} from "~/components/SchedulesTableSkeleton";
-import { StopAlert } from "~/components/StopAlert";
-import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
-import { useAutoRefresh } from "~/hooks/useAutoRefresh";
-import { useApp } from "../AppContext";
-import { GroupedTable } from "../components/GroupedTable";
-import { RegularTable } from "../components/RegularTable";
-import StopDataProvider, { type Stop } from "../data/StopDataProvider";
-import "./estimates-$id.css";
-
-export interface Estimate {
- line: string;
- route: string;
- minutes: number;
- meters: number;
-}
-
-interface ErrorInfo {
- type: "network" | "server" | "unknown";
- status?: number;
- message?: string;
-}
-
-const loadData = async (
- region: RegionId,
- stopId: string
-): Promise<Estimate[]> => {
- const regionConfig = getRegionConfig(region);
- const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, {
- headers: {
- Accept: "application/json",
- },
- });
-
- if (!resp.ok) {
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
- }
-
- return await resp.json();
-};
-
-const loadTimetableData = async (
- region: RegionId,
- stopId: string
-): Promise<ScheduledTable[]> => {
- const regionConfig = getRegionConfig(region);
-
- // Check if timetable is available for this region
- if (!regionConfig.timetableEndpoint) {
- throw new Error("Timetable not available for this region");
- }
-
- // Use "today" to let server determine date based on Europe/Madrid timezone
- const resp = await fetch(
- `${regionConfig.timetableEndpoint}?date=today&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);
-
- // Estimates data state
- const [data, setData] = useState<Estimate[] | null>(null);
- const [dataDate, setDataDate] = useState<Date | null>(null);
- const [estimatesLoading, setEstimatesLoading] = useState(true);
- const [estimatesError, setEstimatesError] = useState<ErrorInfo | null>(null);
-
- // Timetable data state
- const [timetableData, setTimetableData] = useState<ScheduledTable[]>([]);
- const [timetableLoading, setTimetableLoading] = useState(true);
- const [timetableError, setTimetableError] = useState<ErrorInfo | null>(null);
-
- const [favourited, setFavourited] = useState(false);
- const [isManualRefreshing, setIsManualRefreshing] = useState(false);
- 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" };
- }
-
- 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 loadEstimatesData = useCallback(async () => {
- try {
- setEstimatesLoading(true);
- setEstimatesError(null);
-
- const body = await loadData(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 estimates data:", error);
- setEstimatesError(parseError(error));
- setData(null);
- setDataDate(null);
- } finally {
- setEstimatesLoading(false);
- }
- }, [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(region, params.id!);
- setTimetableData(timetableBody);
- } catch (error) {
- console.error("Error loading timetable data:", error);
- setTimetableError(parseError(error));
- setTimetableData([]);
- } finally {
- setTimetableLoading(false);
- }
- }, [params.id, region, regionConfig.timetableEndpoint]);
-
- // 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 loadEstimatesData();
- } finally {
- setIsManualRefreshing(false);
- }
- }, [loadEstimatesData]);
-
- // Auto-refresh estimates data every 30 seconds (only if not in error state)
- useAutoRefresh({
- onRefresh: loadEstimatesData,
- interval: 30000,
- enabled: !estimatesError,
- });
-
- useEffect(() => {
- // Initial load
- 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(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 (estimatesLoading && !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">
- {tableStyle === "grouped" ? (
- <EstimatesGroupedSkeleton />
- ) : (
- <SchedulesTableSkeleton />
- )}
- </div>
-
- <div className="timetable-section">
- <TimetableSkeleton />
- </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 || estimatesLoading}
- 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} mode="rounded" />
- </div>
- ))}
- </div>
- )}
-
- {stopData && <StopAlert stop={stopData} />}
-
- <div className="table-responsive">
- {estimatesLoading ? (
- tableStyle === "grouped" ? (
- <EstimatesGroupedSkeleton />
- ) : (
- <SchedulesTableSkeleton />
- )
- ) : estimatesError ? (
- <ErrorDisplay
- error={estimatesError}
- onRetry={loadEstimatesData}
- title={t(
- "errors.estimates_title",
- "Error al cargar estimaciones"
- )}
- />
- ) : data ? (
- tableStyle === "grouped" ? (
- <GroupedTable
- data={data}
- dataDate={dataDate}
- regionConfig={regionConfig}
- />
- ) : (
- <RegularTable
- data={data}
- dataDate={dataDate}
- regionConfig={regionConfig}
- />
- )
- ) : null}
- </div>
-
- <div className="timetable-section">
- {timetableLoading ? (
- <TimetableSkeleton />
- ) : timetableError ? (
- <ErrorDisplay
- error={timetableError}
- onRetry={loadTimetableDataAsync}
- title={t("errors.timetable_title", "Error al cargar horarios")}
- className="compact"
- />
- ) : timetableData.length > 0 ? (
- <>
- <SchedulesTable
- data={timetableData}
- currentTime={new Date().toTimeString().slice(0, 8)} // HH:MM:SS
- />
- <div className="timetable-actions">
- <Link to={`/timetable/${params.id}`} className="view-all-link">
- <ExternalLink className="external-icon" />
- {t("timetable.viewAll", "Ver todos los horarios")}
- </Link>
- </div>
- </>
- ) : null}
- </div>
- </div>
- </PullToRefresh>
- );
-}
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 31a8e6a..7d8338f 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -124,9 +124,9 @@ export default function StopList() {
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) *
- Math.cos(toRadians(lat2)) *
- Math.sin(dLon / 2) *
- Math.sin(dLon / 2);
+ Math.cos(toRadians(lat2)) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
@@ -160,8 +160,8 @@ export default function StopList() {
// Load favourite and recent IDs immediately from localStorage
useEffect(() => {
- setFavouriteIds(StopDataProvider.getFavouriteIds(region));
- setRecentIds(StopDataProvider.getRecent(region));
+ setFavouriteIds(StopDataProvider.getFavouriteIds());
+ setRecentIds(StopDataProvider.getRecent());
}, [region]);
// Load stops from network
@@ -169,10 +169,10 @@ export default function StopList() {
try {
setLoading(true);
- const stops = await StopDataProvider.loadStopsFromNetwork(region);
+ const stops = await StopDataProvider.loadStopsFromNetwork();
// Add favourite flags to stops
- const favouriteStopsIds = StopDataProvider.getFavouriteIds(region);
+ const favouriteStopsIds = StopDataProvider.getFavouriteIds();
const stopsWithFavourites = stops.map((stop) => ({
...stop,
favourite: favouriteStopsIds.includes(stop.stopId),
@@ -186,7 +186,7 @@ export default function StopList() {
);
setFavouriteStops(favStops);
- const recIds = StopDataProvider.getRecent(region);
+ const recIds = StopDataProvider.getRecent();
const recStops = recIds
.map((id) => stopsWithFavourites.find((stop) => stop.stopId === id))
.filter(Boolean) as Stop[];
@@ -304,8 +304,8 @@ export default function StopList() {
)}
{!loading && data
? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map(
- (stop) => <StopItem key={stop.stopId} stop={stop} />
- )
+ (stop) => <StopItem key={stop.stopId} stop={stop} />
+ )
: null}
</ul>
</div>
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index df4808d..343cf91 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -15,7 +15,7 @@ import Map, {
type StyleSpecification
} from "react-map-gl/maplibre";
import { StopSheet } from "~/components/StopSheet";
-import { getRegionConfig } from "~/config/RegionConfig";
+import { REGION_DATA } from "~/config/RegionConfig";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp } from "../AppContext";
@@ -40,7 +40,7 @@ export default function StopMap() {
>([]);
const [selectedStop, setSelectedStop] = useState<Stop | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
- const { mapState, updateMapState, theme, region } = useApp();
+ const { mapState, updateMapState, theme } = useApp();
const mapRef = useRef<MapRef>(null);
const [mapStyleKey, setMapStyleKey] = useState<string>("light");
@@ -62,7 +62,7 @@ export default function StopMap() {
};
useEffect(() => {
- StopDataProvider.getStops(region).then((data) => {
+ StopDataProvider.getStops().then((data) => {
const features: GeoJsonFeature<
Point,
{ stopId: number; name: string; lines: string[]; cancelled?: boolean }
@@ -81,7 +81,7 @@ export default function StopMap() {
}));
setStops(features);
});
- }, [region]);
+ }, []);
useEffect(() => {
//const styleName = "carto";
@@ -155,7 +155,7 @@ export default function StopMap() {
const stopId = parseInt(props.stopId, 10);
// fetch full stop to get lines array
- StopDataProvider.getStopById(region, stopId)
+ StopDataProvider.getStopById(stopId)
.then((stop) => {
if (!stop) {
console.warn("Stop not found:", stopId);
@@ -186,14 +186,10 @@ export default function StopMap() {
zoom: mapState.zoom,
}}
attributionControl={{ compact: false }}
- maxBounds={
- getRegionConfig(region).bounds
- ? [getRegionConfig(region).bounds!.sw, getRegionConfig(region).bounds!.ne]
- : undefined
- }
+ maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]}
>
<NavigationControl position="top-right" />
- <GeolocateControl position="top-right" trackUserLocation={true} positionOptions={{enableHighAccuracy: false}} />
+ <GeolocateControl position="top-right" trackUserLocation={true} positionOptions={{ enableHighAccuracy: false }} />
<Source
id="stops-source"
@@ -210,8 +206,8 @@ export default function StopMap() {
"icon-image": [
"case",
["coalesce", ["get", "cancelled"], false],
- `stop-${region}-cancelled`,
- `stop-${region}`,
+ `stop-vigo-cancelled`,
+ `stop-vigo`,
],
"icon-size": [
"interpolate",
@@ -243,19 +239,21 @@ export default function StopMap() {
"text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16],
}}
paint={{
- "text-color": `${getRegionConfig(region).textColour || "#000"}`,
+ "text-color": `${REGION_DATA.textColour}`,
"text-halo-color": "#FFF",
"text-halo-width": 1,
}}
/>
- {selectedStop && (
- <StopSheet
- isOpen={isSheetOpen}
- onClose={() => setIsSheetOpen(false)}
- stop={selectedStop}
- />
- )}
- </Map>
+ {
+ selectedStop && (
+ <StopSheet
+ isOpen={isSheetOpen}
+ onClose={() => setIsSheetOpen(false)}
+ stop={selectedStop}
+ />
+ )
+ }
+ </Map >
);
}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index 351ccf0..faad5a6 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -1,76 +1,41 @@
-import { useState } from "react";
+import { Computer, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { type Theme, useApp } from "../AppContext";
-import { getAvailableRegions } from "../config/RegionConfig";
import "./settings.css";
export default function Settings() {
const { t, i18n } = useTranslation();
usePageTitle(t("navbar.settings", "Ajustes"));
- const navigate = useNavigate();
const {
theme,
setTheme,
- tableStyle,
- setTableStyle,
mapPositionMode,
- setMapPositionMode,
- region,
- setRegion,
+ setMapPositionMode
} = useApp();
- const regions = getAvailableRegions();
- const [showModal, setShowModal] = useState(false);
- const [pendingRegion, setPendingRegion] = useState<string | null>(null);
-
- const handleRegionChange = (newRegion: string) => {
- if (newRegion !== region) {
- setPendingRegion(newRegion);
- setShowModal(true);
- }
- };
-
- const confirmRegionChange = () => {
- if (pendingRegion) {
- setRegion(pendingRegion as any);
- setShowModal(false);
- setPendingRegion(null);
- navigate("/");
- }
- };
-
- const cancelRegionChange = () => {
- setShowModal(false);
- setPendingRegion(null);
- };
-
return (
<div className="page-container">
<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) => handleRegionChange(e.target.value)}
- >
- {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>
+
+ <div className="flex">
+ <button onClick={() => setTheme("light")}>
+ <Sun />
+ </button>
+ <button onClick={() => setTheme("dark")}>
+ <Moon />
+ </button>
+ <button onClick={() => setTheme("system")}>
+ <Computer />
+ </button>
+ </div>
+
<select
id="theme"
className="form-select-inline"
@@ -82,6 +47,7 @@ export default function Settings() {
<option value="system">{t("about.theme_system")}</option>
</select>
</div>
+
<div className="settings-content-inline">
<label htmlFor="mapPositionMode" className="form-label-inline">
{t("about.map_position_mode")}
@@ -114,71 +80,7 @@ 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"
- | "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>
- <dl>
- <dt>{t("about.table_style_regular")}</dt>
- <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>
-
- {showModal && (
- <div className="modal-overlay" onClick={cancelRegionChange}>
- <div className="modal-content" onClick={(e) => e.stopPropagation()}>
- <h2>{t("about.region_change_title", "Cambiar región")}</h2>
- <p>
- {t(
- "about.region_change_message",
- "¿Estás seguro de que quieres cambiar la región? Serás redirigido a la lista de paradas."
- )}
- </p>
- <div className="modal-buttons">
- <button
- className="modal-button modal-button-cancel"
- onClick={cancelRegionChange}
- >
- {t("about.cancel", "Cancelar")}
- </button>
- <button
- className="modal-button modal-button-confirm"
- onClick={confirmRegionChange}
- >
- {t("about.confirm", "Confirmar")}
- </button>
- </div>
- </div>
- </div>
- )}
</div>
);
}
diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css
index 4d204a7..1144584 100644
--- a/src/frontend/app/routes/stops-$id.css
+++ b/src/frontend/app/routes/stops-$id.css
@@ -13,7 +13,7 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
- padding-block: 0 1rem;
+ margin-block: 0 1rem;
}
.table {
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index de552bd..cdc74eb 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { ErrorDisplay } from "~/components/ErrorDisplay";
import LineIcon from "~/components/LineIcon";
+import { PullToRefresh } from "~/components/PullToRefresh";
import { StopAlert } from "~/components/StopAlert";
import { StopMapModal } from "~/components/StopMapModal";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
+import { REGION_DATA } from "~/config/RegionConfig";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
-import { useApp } from "../AppContext";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import "./stops-$id.css";
@@ -53,12 +53,10 @@ interface ErrorInfo {
}
const loadConsolidatedData = async (
- region: RegionId,
stopId: string
): Promise<ConsolidatedCirculation[]> => {
- const regionConfig = getRegionConfig(region);
const resp = await fetch(
- `${regionConfig.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
{
headers: {
Accept: "application/json",
@@ -92,8 +90,6 @@ export default function Estimates() {
const [selectedCirculationId, setSelectedCirculationId] = useState<
string | undefined
>(undefined);
- const { region } = useApp();
- const regionConfig = getRegionConfig(region);
// Helper function to get the display name for the stop
const getStopDisplayName = useCallback(() => {
@@ -131,14 +127,14 @@ export default function Estimates() {
setDataLoading(true);
setDataError(null);
- const body = await loadConsolidatedData(region, params.id!);
+ const body = await loadConsolidatedData(params.id!);
setData(body);
setDataDate(new Date());
// Load stop data from StopDataProvider
- const stop = await StopDataProvider.getStopById(region, stopIdNum);
+ const stop = await StopDataProvider.getStopById(stopIdNum);
setStopData(stop);
- setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
+ setCustomName(StopDataProvider.getCustomName(stopIdNum));
} catch (error) {
console.error("Error loading consolidated data:", error);
setDataError(parseError(error));
@@ -147,24 +143,21 @@ export default function Estimates() {
} finally {
setDataLoading(false);
}
- }, [params.id, stopIdNum, region]);
+ }, [params.id, stopIdNum]);
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: 12000,
@@ -175,18 +168,18 @@ export default function Estimates() {
// Initial load
loadData();
- StopDataProvider.pushRecent(region, parseInt(params.id ?? ""));
+ StopDataProvider.pushRecent(parseInt(params.id ?? ""));
setFavourited(
- StopDataProvider.isFavourite(region, parseInt(params.id ?? ""))
+ StopDataProvider.isFavourite(parseInt(params.id ?? ""))
);
- }, [params.id, region, loadData]);
+ }, [params.id, loadData]);
const toggleFavourite = () => {
if (favourited) {
- StopDataProvider.removeFavourite(region, stopIdNum);
+ StopDataProvider.removeFavourite(stopIdNum);
setFavourited(false);
} else {
- StopDataProvider.addFavourite(region, stopIdNum);
+ StopDataProvider.addFavourite(stopIdNum);
setFavourited(true);
}
};
@@ -197,16 +190,16 @@ export default function Estimates() {
if (input === null) return; // cancelled
const trimmed = input.trim();
if (trimmed === "") {
- StopDataProvider.removeCustomName(region, stopIdNum);
+ StopDataProvider.removeCustomName(stopIdNum);
setCustomName(undefined);
} else {
- StopDataProvider.setCustomName(region, stopIdNum, trimmed);
+ StopDataProvider.setCustomName(stopIdNum, trimmed);
setCustomName(trimmed);
}
};
return (
- <>
+ <PullToRefresh onRefresh={handleManualRefresh}>
<div className="page-container stops-page">
<div className="stops-header">
<div>
@@ -238,7 +231,7 @@ export default function Estimates() {
<div className={`estimates-lines-container scrollable`}>
{stopData.lines.map((line) => (
<div key={line} className="estimates-line-icon">
- <LineIcon line={line} region={region} mode="rounded" />
+ <LineIcon line={line} mode="rounded" />
</div>
))}
</div>
@@ -262,7 +255,6 @@ export default function Estimates() {
<ConsolidatedCirculationList
data={data}
dataDate={dataDate}
- regionConfig={regionConfig}
onCirculationClick={(estimate, idx) => {
setSelectedCirculationId(getCirculationId(estimate));
setIsMapModalOpen(true);
@@ -271,11 +263,9 @@ export default function Estimates() {
) : null}
</div>
- {/* Map Modal - only render if we have stop data */}
{stopData && (
<StopMapModal
stop={stopData}
- region={region}
circulations={(data ?? []).map((c) => ({
id: getCirculationId(c),
line: c.line,
@@ -285,8 +275,8 @@ export default function Estimates() {
previousTripShapeId: c.previousTripShapeId,
schedule: c.schedule
? {
- shapeId: c.schedule.shapeId,
- }
+ shapeId: c.schedule.shapeId,
+ }
: undefined,
}))}
isOpen={isMapModalOpen}
@@ -295,6 +285,6 @@ export default function Estimates() {
/>
)}
</div>
- </>
+ </PullToRefresh>
);
}
diff --git a/src/frontend/app/routes/timetable-$id.css b/src/frontend/app/routes/timetable-$id.css
deleted file mode 100644
index 3815982..0000000
--- a/src/frontend/app/routes/timetable-$id.css
+++ /dev/null
@@ -1,224 +0,0 @@
-.timetable-full-header {
- margin-bottom: 2rem;
-}
-
-.back-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- margin-bottom: 1rem;
- font-weight: 500;
- transition: color 0.2s ease;
-}
-
-.back-link:hover {
- color: var(--link-hover-color, #0056b3);
- text-decoration: underline;
-}
-
-.back-icon {
- width: 1.2rem;
- height: 1.2rem;
-}
-
-.page-title .stop-name {
- font-size: 1.2rem;
- font-weight: 600;
- color: var(--text-primary, #333);
-}
-
-.page-title .stop-id {
- font-size: 1rem;
- color: var(--text-secondary, #666);
- font-weight: normal;
- margin-left: 0.5rem;
-}
-
-.timetable-full-content {
- margin-top: 1rem;
- position: relative;
- padding-bottom: 80px; /* Space for FAB */
-}
-
-.error-message {
- text-align: center;
- padding: 3rem 2rem;
- background-color: var(--error-background, #f8f9fa);
- border: 1px solid var(--error-border, #dee2e6);
- border-radius: 8px;
- margin: 2rem 0;
-}
-
-.error-message p {
- margin-bottom: 1rem;
- color: var(--error-color, #dc3545);
- font-weight: 500;
-}
-
-.error-detail {
- font-size: 0.9rem;
- color: var(--text-secondary, #666) !important;
- font-weight: normal !important;
-}
-
-.timetable-controls {
- margin-bottom: 1.5rem;
- display: flex;
- justify-content: center;
-}
-
-.past-toggle {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--link-color, #007bff);
- text-decoration: none;
- font-weight: 500;
- padding: 0.5rem 1rem;
- border: 1px solid var(--link-color, #007bff);
- border-radius: 6px;
- background: transparent;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.past-toggle:hover {
- background-color: var(--link-color, #007bff);
- color: white;
- text-decoration: none;
-}
-
-.past-toggle:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.past-toggle:disabled:hover {
- background: transparent;
- color: var(--link-color, #007bff);
-}
-
-.past-toggle.active {
- background-color: var(--link-color, #007bff);
- color: white;
- border-color: var(--link-color, #007bff);
-}
-
-.toggle-icon {
- width: 1rem;
- height: 1rem;
-}
-
-/* Next entry highlight */
-.timetable-card.timetable-next {
- border: 2px solid var(--accent-color, #28a745);
- background: var(--surface-next, #e8f5e8) !important;
-}
-
-/* Override timetable cards styles for full page */
-.timetable-full-content .timetable-cards {
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.timetable-full-content .timetable-caption {
- font-size: 1.2rem;
- margin-bottom: 1.5rem;
-}
-
-.timetable-full-content .timetable-card {
- padding: 1.25rem;
-}
-
-/* Responsive design */
-@media (max-width: 768px) {
- .page-title {
- font-size: 1.5rem;
- }
-
- .page-title .stop-name {
- font-size: 1.1rem;
- }
-
- .timetable-full-content .timetable-cards {
- gap: 0.75rem;
- }
-
- .timetable-full-content .timetable-card {
- padding: 1rem;
- }
-}
-
-/* Floating Action Button */
-.fab-container {
- position: fixed;
- bottom: 80px;
- right: 20px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- z-index: 1000;
-}
-
-.fab {
- width: 56px;
- height: 56px;
- border-radius: 50%;
- border: none;
- background-color: var(--button-background-color, #007bff);
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- transition: all 0.3s ease;
- animation: fadeIn 0.3s ease;
-}
-
-.fab:hover {
- background-color: var(--button-hover-background-color, #0069d9);
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
- transform: scale(1.05);
-}
-
-.fab:active {
- transform: scale(0.95);
-}
-
-.fab-icon {
- width: 24px;
- height: 24px;
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Adjust FAB position on mobile */
-@media (max-width: 768px) {
- .fab-container {
- bottom: 70px;
- right: 16px;
- }
-
- .fab {
- width: 48px;
- height: 48px;
- }
-
- .fab-icon {
- width: 20px;
- height: 20px;
- }
-}
diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx
deleted file mode 100644
index c036cb3..0000000
--- a/src/frontend/app/routes/timetable-$id.tsx
+++ /dev/null
@@ -1,570 +0,0 @@
-import {
- ArrowLeft,
- ChevronDown,
- ChevronUp,
- Clock,
- Eye,
- EyeOff,
-} from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Link, useParams } from "react-router";
-import { useApp } from "~/AppContext";
-import { ErrorDisplay } from "~/components/ErrorDisplay";
-import { type ScheduledTable } from "~/components/SchedulesTable";
-import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
-import LineIcon from "../components/LineIcon";
-import StopDataProvider from "../data/StopDataProvider";
-import "./timetable-$id.css";
-
-interface ErrorInfo {
- type: "network" | "server" | "unknown";
- status?: number;
- message?: string;
-}
-
-const loadTimetableData = async (
- region: RegionId,
- stopId: string
-): Promise<ScheduledTable[]> => {
- 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));
-
- // Use "today" to let server determine date based on Europe/Madrid timezone
- const resp = await fetch(
- `${regionConfig.timetableEndpoint}?date=today&stopId=${stopId}`,
- {
- headers: {
- Accept: "application/json",
- },
- }
- );
-
- if (!resp.ok) {
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
- }
-
- return await resp.json();
-};
-
-// Utility function to compare times
-const timeToMinutes = (time: string): number => {
- const [hours, minutes] = time.split(":").map(Number);
- return hours * 60 + minutes;
-};
-
-// Utility function to format GTFS time for display (handle hours >= 24)
-const formatTimeForDisplay = (time: string): string => {
- const [hours, minutes] = time.split(":").map(Number);
- const normalizedHours = hours % 24;
- return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
-};
-
-// Filter past entries (keep only a few recent past ones)
-const filterTimetableData = (
- data: ScheduledTable[],
- currentTime: string,
- showPast: boolean = false
-): ScheduledTable[] => {
- if (showPast) return data;
-
- const currentMinutes = timeToMinutes(currentTime);
- const sortedData = [...data].sort(
- (a, b) => timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
- );
-
- // Find the current position
- const currentIndex = sortedData.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
- );
-
- if (currentIndex === -1) {
- // All entries are in the past, show last 3
- return sortedData.slice(-3);
- }
-
- // Show 3 past entries + all future entries
- const startIndex = Math.max(0, currentIndex - 3);
- return sortedData.slice(startIndex);
-};
-
-// Utility function to parse service ID and get the turn number
-const parseServiceId = (serviceId: string): string => {
- const parts = serviceId.split("_");
- if (parts.length === 0) return "";
-
- const lastPart = parts[parts.length - 1];
- if (lastPart.length < 6) return "";
-
- const last6 = lastPart.slice(-6);
- const lineCode = last6.slice(0, 3);
- const turnCode = last6.slice(-3);
-
- // Remove leading zeros from turn
- const turnNumber = parseInt(turnCode, 10).toString();
-
- // Parse line number with special cases
- const lineNumber = parseInt(lineCode, 10);
- let displayLine: string;
-
- switch (lineNumber) {
- case 1:
- displayLine = "C1";
- break;
- case 3:
- displayLine = "C3";
- break;
- case 30:
- displayLine = "N1";
- break;
- case 33:
- displayLine = "N4";
- break;
- case 8:
- displayLine = "A";
- break;
- case 101:
- displayLine = "H";
- break;
- case 150:
- displayLine = "REF";
- break;
- case 500:
- displayLine = "TUR";
- break;
- default:
- displayLine = `L${lineNumber}`;
- }
-
- return `${displayLine}-${turnNumber}`;
-};
-
-// Scroll threshold for showing FAB buttons (in pixels)
-const SCROLL_THRESHOLD = 100;
-
-export default function Timetable() {
- const { t } = useTranslation();
- const { region } = useApp();
- const params = useParams();
- const stopIdNum = parseInt(params.id ?? "");
- const [timetableData, setTimetableData] = useState<ScheduledTable[]>([]);
- const [customName, setCustomName] = useState<string | undefined>(undefined);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<ErrorInfo | null>(null);
- const [showPastEntries, setShowPastEntries] = useState(false);
- const nextEntryRef = useRef<HTMLDivElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const regionConfig = getRegionConfig(region);
-
- const currentTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS
- const filteredData = filterTimetableData(
- timetableData,
- currentTime,
- showPastEntries
- );
-
- 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 = 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(region, params.id!);
- setTimetableData(timetableBody);
-
- if (timetableBody.length > 0) {
- // Scroll to next entry after a short delay to allow rendering
- setTimeout(() => {
- const currentMinutes = timeToMinutes(currentTime);
- const sortedData = [...timetableBody].sort(
- (a, b) =>
- timeToMinutes(a.calling_time) - timeToMinutes(b.calling_time)
- );
-
- const nextIndex = sortedData.findIndex(
- (entry) => timeToMinutes(entry.calling_time) >= currentMinutes
- );
-
- if (nextIndex !== -1 && nextEntryRef.current) {
- nextEntryRef.current.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- }
- }, 500);
- }
- } catch (err) {
- console.error("Error loading timetable data:", err);
- setError(parseError(err));
- setTimetableData([]);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- loadData();
- setCustomName(StopDataProvider.getCustomName(region, stopIdNum));
- }, [params.id, region]);
-
- // Scroll FABs moved to ScrollFabManager component
-
- if (loading) {
- return (
- <div className="page-container">
- <div className="timetable-full-header">
- <h1 className="page-title">
- {t("timetable.fullTitle", "Horarios teóricos")} ({params.id})
- </h1>
- <Link to={`/estimates/${params.id}`} className="back-link">
- <ArrowLeft className="back-icon" />
- {t("timetable.backToEstimates", "Volver a estimaciones")}
- </Link>
- </div>
-
- <div className="timetable-full-content">
- <div className="timetable-controls">
- <button className="past-toggle" disabled>
- <Eye className="toggle-icon" />
- {t("timetable.showPast", "Mostrar todos")}
- </button>
- </div>
-
- <TimetableSkeleton rows={8} />
- </div>
- </div>
- );
- }
-
- return (
- <div className="page-container">
- <div className="timetable-full-header">
- <h1 className="page-title">
- {t("timetable.fullTitle", "Horarios teóricos")} ({params.id})
- </h1>
- <Link to={`/estimates/${params.id}`} className="back-link">
- <ArrowLeft className="back-icon" />
- {t("timetable.backToEstimates", "Volver a estimaciones")}
- </Link>
- </div>
-
- {error ? (
- <div className="timetable-full-content">
- <ErrorDisplay
- error={error}
- onRetry={loadData}
- title={t("errors.timetable_title", "Error al cargar horarios")}
- />
- </div>
- ) : timetableData.length === 0 ? (
- <div className="error-message">
- <p>
- {t(
- "timetable.noDataAvailable",
- "No hay datos de horarios disponibles para hoy"
- )}
- </p>
- <p className="error-detail">
- {t(
- "timetable.errorDetail",
- "Los horarios teóricos se actualizan diariamente. Inténtalo más tarde."
- )}
- </p>
- </div>
- ) : (
- <div className="timetable-full-content" ref={containerRef}>
- <div className="timetable-controls">
- <button
- className={`past-toggle ${showPastEntries ? "active" : ""}`}
- onClick={() => setShowPastEntries(!showPastEntries)}
- >
- {showPastEntries ? (
- <>
- <EyeOff className="toggle-icon" />
- {t("timetable.hidePast", "Ocultar pasados")}
- </>
- ) : (
- <>
- <Eye className="toggle-icon" />
- {t("timetable.showPast", "Mostrar todos")}
- </>
- )}
- </button>
- </div>
-
- <TimetableTableWithScroll
- data={filteredData}
- showAll={true}
- currentTime={currentTime}
- nextEntryRef={nextEntryRef}
- />
-
- {/* Floating Action Button */}
- <ScrollFabManager
- containerRef={containerRef}
- nextEntryRef={nextEntryRef}
- currentTime={currentTime}
- data={filteredData}
- disabled={loading || !!error || timetableData.length === 0}
- />
- </div>
- )}
- </div>
- );
-}
-
-// Custom component for the full timetable with scroll reference
-const TimetableTableWithScroll: React.FC<{
- data: ScheduledTable[];
- showAll: boolean;
- currentTime: string;
- nextEntryRef: React.RefObject<HTMLDivElement | null>;
-}> = ({ data, showAll, currentTime, nextEntryRef }) => {
- const { t } = useTranslation();
- const { region } = useApp();
- const nowMinutes = timeToMinutes(currentTime);
-
- return (
- <div className="timetable-container">
- <div className="timetable-caption">
- {t("timetable.fullCaption", "Horarios teóricos de la parada")}
- </div>
-
- <div className="timetable-cards">
- {data.map((entry, index) => {
- const entryMinutes = timeToMinutes(entry.calling_time);
- const isPast = entryMinutes < nowMinutes;
- const isNext =
- !isPast &&
- (index === 0 ||
- timeToMinutes(data[index - 1]?.calling_time || "00:00:00") <
- nowMinutes);
-
- return (
- <div
- key={`${entry.trip_id}-${index}`}
- ref={isNext ? nextEntryRef : null}
- className={`timetable-card${isPast ? " timetable-past" : ""}${isNext ? " timetable-next" : ""}`}
- style={{
- background: isPast
- ? "var(--surface-past, #f3f3f3)"
- : isNext
- ? "var(--surface-next, #e8f5e8)"
- : "var(--surface-future, #fff)",
- }}
- >
- <div className="card-header">
- <div className="line-info">
- <LineIcon line={entry.line} region={region} />
- </div>
-
- <div className="destination-info">
- {entry.route && entry.route.trim() ? (
- <strong>{entry.route}</strong>
- ) : (
- <strong>
- {t("timetable.noDestination", "Línea")} {entry.line}
- </strong>
- )}
- </div>
-
- <div className="time-info">
- <span className="departure-time">
- {formatTimeForDisplay(entry.calling_time)}
- </span>
- <div className="service-id">
- {parseServiceId(entry.service_id)}
- </div>
- </div>
- </div>
- <div className="card-body">
- {!isPast && entry.next_streets.length > 0 && (
- <div className="route-streets">
- {entry.next_streets.join(" — ")}
- </div>
- )}
- </div>
- </div>
- );
- })}
- </div>
-
- {data.length === 0 && (
- <p className="no-data">
- {t("timetable.noData", "No hay datos de horarios disponibles")}
- </p>
- )}
- </div>
- );
-};
-
-// Component to manage scroll-based FAB visibility globally within timetable
-const ScrollFabManager: React.FC<{
- containerRef: React.RefObject<HTMLDivElement | null>;
- nextEntryRef: React.RefObject<HTMLDivElement | null>;
- currentTime: string;
- data: ScheduledTable[];
- disabled?: boolean;
-}> = ({ containerRef, nextEntryRef, currentTime, data, disabled = false }) => {
- const { t } = useTranslation();
- const [showScrollTop, setShowScrollTop] = useState(false);
- const [showScrollBottom, setShowScrollBottom] = useState(false);
- const [showGoToNow, setShowGoToNow] = useState(false);
-
- // Find the actual scrollable ancestor (.main-content) if our container isn't scrollable
- const getScrollContainer = () => {
- let el: HTMLElement | null = containerRef.current;
- while (el) {
- const style = getComputedStyle(el);
- const hasScroll = el.scrollHeight > el.clientHeight + 8;
- const overflowY = style.overflowY;
- if (hasScroll && (overflowY === "auto" || overflowY === "scroll")) {
- return el;
- }
- el = el.parentElement;
- }
- return null;
- };
-
- useEffect(() => {
- if (disabled) return;
- const scrollEl = getScrollContainer();
- const useWindowScroll = !scrollEl;
-
- const handleScroll = () => {
- const scrollTop = useWindowScroll
- ? window.scrollY || document.documentElement.scrollTop || 0
- : scrollEl!.scrollTop;
- const scrollHeight = useWindowScroll
- ? document.documentElement.scrollHeight
- : scrollEl!.scrollHeight;
- const clientHeight = useWindowScroll
- ? window.innerHeight
- : scrollEl!.clientHeight;
-
- const scrollBottom = scrollHeight - scrollTop - clientHeight;
- const threshold = 80; // slightly smaller threshold for responsiveness
- setShowScrollTop(scrollTop > threshold);
- setShowScrollBottom(scrollBottom > threshold);
-
- if (nextEntryRef.current) {
- const rect = nextEntryRef.current.getBoundingClientRect();
- const isNextVisible =
- rect.top >= 0 && rect.bottom <= window.innerHeight;
- setShowGoToNow(!isNextVisible);
- }
- };
-
- const target: any = useWindowScroll ? window : scrollEl!;
- target.addEventListener("scroll", handleScroll, { passive: true });
- window.addEventListener("resize", handleScroll);
- handleScroll();
- return () => {
- target.removeEventListener("scroll", handleScroll);
- window.removeEventListener("resize", handleScroll);
- };
- }, [containerRef, nextEntryRef, disabled, data, currentTime]);
-
- const scrollToTop = () => {
- const scrollEl = getScrollContainer();
- if (!scrollEl) {
- window.scrollTo({ top: 0, behavior: "smooth" });
- } else {
- scrollEl.scrollTo({ top: 0, behavior: "smooth" });
- }
- };
- const scrollToBottom = () => {
- const scrollEl = getScrollContainer();
- if (!scrollEl) {
- window.scrollTo({
- top: document.documentElement.scrollHeight,
- behavior: "smooth",
- });
- } else {
- scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: "smooth" });
- }
- };
- const scrollToNow = () => {
- nextEntryRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- };
-
- if (disabled) return null;
- if (!(showGoToNow || showScrollTop || showScrollBottom)) return null;
-
- return (
- <div className="fab-container">
- {showGoToNow && !showScrollTop && !showScrollBottom && (
- <button
- className="fab fab-now"
- onClick={scrollToNow}
- title={t("timetable.goToNow", "Ir a ahora")}
- aria-label={t("timetable.goToNow", "Ir a ahora")}
- >
- <Clock className="fab-icon" />
- </button>
- )}
- {showScrollTop && (
- <button
- className="fab fab-up"
- onClick={scrollToTop}
- title={t("timetable.scrollUp", "Subir")}
- aria-label={t("timetable.scrollUp", "Subir")}
- >
- <ChevronUp className="fab-icon" />
- </button>
- )}
- {showScrollBottom && !showScrollTop && (
- <button
- className="fab fab-down"
- onClick={scrollToBottom}
- title={t("timetable.scrollDown", "Bajar")}
- aria-label={t("timetable.scrollDown", "Bajar")}
- >
- <ChevronDown className="fab-icon" />
- </button>
- )}
- </div>
- );
-};
diff --git a/src/frontend/app/tailwind.css b/src/frontend/app/tailwind.css
new file mode 100644
index 0000000..de604f7
--- /dev/null
+++ b/src/frontend/app/tailwind.css
@@ -0,0 +1,6 @@
+@layer theme, base, components, utilities;
+
+@import "tailwindcss/theme.css" layer(theme);
+@import "tailwindcss/utilities.css" layer(utilities);
+
+@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index 98bd7be..65509fc 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -16,7 +16,7 @@
"fuse.js": "^7.1.0",
"i18next-browser-languagedetector": "^8.2.0",
"isbot": "^5",
- "lucide-react": "^0.554.0",
+ "lucide-react": "^0.555.0",
"maplibre-theme": "^1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -4947,9 +4947,9 @@
}
},
"node_modules/lucide-react": {
- "version": "0.554.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
- "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
+ "version": "0.555.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
+ "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 53d23ef..38d05f9 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -22,7 +22,7 @@
"fuse.js": "^7.1.0",
"i18next-browser-languagedetector": "^8.2.0",
"isbot": "^5",
- "lucide-react": "^0.554.0",
+ "lucide-react": "^0.555.0",
"maplibre-theme": "^1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",